Swift,Objective-Cプログラミング ~ iOS ~

Objective-C,Swift,Apple Watchなどのプログラミング

クリーンアーキテクチャを試し実装して考えたこと その001

・View、Presenter、Usecase、Repository、DataStoreに層を分けて実装している ・DataStoreのModelデータ←→Usecaseより前の層で扱うデータ(Valueとよんでおく)の変換はRepositoryで行う ・DataStoreへのデータ追加がある場合、View、Presenter、UsecaseのどこかでValueデータを作成する。今のところ、Usecaseで行うのがいいのかなと感じている ・Valueデータを作成にはValueCreatorプロトコルを用意し、Modelデータ←→ValueデータにはTranslatorプロトコルを用意している

Alamofireでのステータスコードとコンテンツタイプのハンドリング

はじめに

Alamofireではデフォルトではレスポンスの内容にかかわらず成功として処理される。

例えば下記のようなリクエストの場合、responseを取得できる。

AF.request("https://api.example.com").responseJSON { response in
    // handling response
}

この中にはURLRequestやHTTPURLResponse、Dataなどが含まれている。

サーバーからのレスポンスをそのまま返し、Alamofireではステータスコードと コンテンツタイプを見てエラーかどうかは処理されない。

ステータスコードとコンテンツタイプのハンドリング

コールバックされるクロージャのなかで自分でハンドリングするのも良いのですが、 ステータスコードとコンテンツタイプには簡単にハンドリングするための方法がAlamofireで用意されている。

ステータスコード(status code)

validate(statusCode:)メソッドを使う。 指定したステータスコード以外の場合はAFError.responseValidationFailedエラーが返される。

AF.request("https://api.example.com")
    .validate(statusCode: 200..<300)
    .responseJSON { response in
    // handling response
}

コンテンツタイプ(content type)

validate(contentType:)メソッドを使う。 指定したコンテンツタイプ以外の場合はAFError.responseValidationFailedエラーが返される。

AF.request("https://api.example.com")
    .validate(contentType: ["application/json"])
    .responseJSON { response in
    // handling response
}

デフォルト

引数なしのvalidate()メソッドを使う場合、 デフォルトのステータスコード(status code)とコンテンツタイプ(content type)をチェックしてくれる。

AF.request("https://api.example.com")
    .validate()
    .responseJSON { response in
    // handling response
}

デフォルトの値は以下の通り。

fileprivate var acceptableStatusCodes: Range<Int> { 200..<300 }

fileprivate var acceptableContentTypes: [String] {
    if let accept = request?.value(forHTTPHeaderField: "Accept") {
        return accept.components(separatedBy: ",")
    }

    return ["*/*"]
}

【Swift】日付を含むJSONをCodableでDate型に変換する

はじめに

日付を含むJSONをCodableでDate型にする方法をメモしておく。 日付の表現は複数あります。

例えば、下記の表現があります。

  • 2020-07-25
  • 2020/07/25
  • TimeInterval
  • その他

変換したいJSONで使われている日付表現に合わせて、設定する必要があります。

Date型への変換

JSONを変換するときに使用するJSONDecoderはデフォルトでは00:00:00 UTC on 1 January 2001からのTimeInterval形式(数値)を変換できます。

その他の日付表現を変換する場合はdateDecodingStrategyプロパティでどのようなフォーマットなのかを指定する必要があります。

デフォルトの変換形式

00:00:00 UTC on 1 January 2001からのTimeInterval形式(数値)を変換できます。

JSONで指定されるTimeIntervalは

00:00:00 UTC on 1 January 2001

からの経過秒数である必要があります。 おそらくデフォルトではDate型の

init(timeIntervalSinceReferenceDate ti: TimeInterval)

https://developer.apple.com/documentation/foundation/nsdate/1409769-init

を使って変換しているのだと思います。

サンプル

import Foundation

let jsonString = """
{
    "createdAt": 617339567.66283596

}
"""

struct DateObject: Codable {
    let createdAt: Date
}

let decoder = JSONDecoder()
let result = try decoder.decode(DateObject.self, from: jsonString.data(using: .utf8)!)

print(result.createdAt) // 2020-07-25 03:12:47 +0000

ISO8601

dateDecodingStrategyでiso8601を指定します。

サンプル

import Foundation

let jsonString = """
{
    "createdAt": "2020-07-25T03:12:47-00:00"

}
"""

struct DateObject: Codable {
    let createdAt: Date
}

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let result = try decoder.decode(DateObject.self, from: jsonString.data(using: .utf8)!)

print(result.createdAt) // 2020-07-25 03:12:47 +0000

UnixTime(秒指定)

dateDecodingStrategyでsecondsSince1970を指定します。

サンプル

import Foundation

let jsonString = """
{
    "createdAt": 1595646767

}
"""

struct DateObject: Codable {
    let createdAt: Date
}

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970
let result = try decoder.decode(DateObject.self, from: jsonString.data(using: .utf8)!)

print(result.createdAt) // 2020-07-25 03:12:47 +0000

UnixTime(ミリ秒指定)

dateDecodingStrategyでsecondsSince1970を指定します。

サンプル

import Foundation

let jsonString = """
{
    "createdAt": 1595646767000

}
"""

struct DateObject: Codable {
    let createdAt: Date
}

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .millisecondsSince1970
let result = try decoder.decode(DateObject.self, from: jsonString.data(using: .utf8)!)

print(result.createdAt) // 2020-07-25 03:12:47 +0000

その他文字列

dateDecodingStrategyでformattedに使用するフォーマットを加えて指定します。

サンプル

import Foundation

let jsonString = """
{
    "createdAt": "2020-07-25 03:12:47+00:00"

}
"""

struct DateObject: Codable {
    let createdAt: Date
}

let decoder = JSONDecoder()
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ssZ"
decoder.dateDecodingStrategy = .formatted(formatter)
let result = try decoder.decode(DateObject.self, from: jsonString.data(using: .utf8)!)

print(result.createdAt) // 2020-07-25 03:12:47 +0000

【SwiftUI】一画面で複数のモーダルを出し分けする

はじめに

画面で2つの種類のモーダルを出すときにどのように実装したらよいかを考えました。(例えば、新規追加画面と編集画面など)

アプリ起動時 f:id:fjswkun:20200720092107p:plain

addボタンタップ時 f:id:fjswkun:20200720092119p:plain

editボタンタップ時 f:id:fjswkun:20200720092131p:plain

実装例

パターン1

モーダルを起動するボタンごとにsheet修飾子でモーダル表示をする。 デザインとアクションの実装が続き、読みにくい

struct ContentView: View {
    @State var isShowAddSheet = false
    @State var isShowEditSheet = false
    
    var body: some View {
        VStack {
            Text("Hello, World!")
            Button("add") {
                self.isShowAddSheet = true
            }
            .sheet(isPresented: $isShowAddSheet) {
                Text("add").background(Color.yellow)
            }
            Button("edit") {
                self.isShowEditSheet = true
            }.sheet(isPresented: $isShowEditSheet) {
                Text("edit").background(Color.green)
            }
        }
    }
}

パターン2

デザインとアクションが分けられてソースを読みやすくなった気がするが、やりたいことを実現できないのでNG。Viewはsheetをひとつしか持てないようで、addはボタンを押してもモーダル表示されませんでした。editボタンは問題なくモーダル表示されました。

import SwiftUI

struct ContentView: View {
    @State var isShowAddSheet = false
    @State var isShowEditSheet = false
    
    var body: some View {
        VStack {
            Text("Hello, World!")
            Button("add") {
                self.isShowAddSheet = true
            }
            
            Button("edit") {
                self.isShowEditSheet = true
            }
        }
        .sheet(isPresented: $isShowAddSheet) {
            Text("add").background(Color.yellow)
        }
        .sheet(isPresented: $isShowEditSheet) {
            Text("edit").background(Color.green)
        }
    }
}

パターン3

  • モーダル表示にはitem引数を持つ方のsheet修飾子を使う
  • item引数には出し分けするモーダルを表すenumを作って使う

モーダル表示にはitem引数を持つ方のsheet修飾子を使う

こちらを使う

public func sheet<Item, Content>(item: Binding<Item?>, onDismiss: (() -> Void)? = nil, @ViewBuilder content: @escaping (Item) -> Content) -> some View where Item : Identifiable, Content : View

item引数には出し分けするモーダルを表すenumを作って使う

画面で新規追加画面と編集画面の2つを出すとします。

下記のようなenumを作成します。

enum SheetType: Int, Identifiable {
    case add
    case edit
    
    var id: Int {
        return self.rawValue
    }
}

パターン3実装

import SwiftUI

struct ContentView: View {
    @State var sheetType: SheetType? = nil
    
    var body: some View {
        VStack {
            Text("Hello, World!")
            Button("add") {
                self.sheetType = .add
            }
            Button("edit") {
                self.sheetType = .edit
            }
            
        }
        .background(Color.gray)
        .sheet(item: self.$sheetType) { (t) -> AnyView in
            self.showSheet(type: t)
        }
    }
    
    private func showSheet(type: SheetType) -> AnyView {
        switch type {
        case .add:
            return AnyView(Text("add").background(Color.yellow))
        case .edit:
            return AnyView(Text("edit").background(Color.green))
        }
    }
    
    enum SheetType: Int, Identifiable {
        case add
        case edit
        
        var id: Int {
            return self.rawValue
        }
    }
}

実行

アプリ起動時 f:id:fjswkun:20200720092107p:plain

addボタンタップ時 f:id:fjswkun:20200720092119p:plain

editボタンタップ時 f:id:fjswkun:20200720092131p:plain

結論

他にもっといい方法があるかもしれないが、ひとまずenumを作ってやっておくのが良いとして実装した。

複数のViewを横並びにしていて、表示しきれない場合に折り返す表示を作る

はじめに

基本的には横並びにViewを並べたい。でも、iPhoneSEなどの横幅が小さい端末では表示しきれないので、その場合に折り返して、表示しきれなかったViewを表示したい。

たとえば、下記画像のような検索条件設定画面があるとします。

f:id:fjswkun:20200710150810p:plain

これはiPhone11の画像です。 検索条件で検索する商品のカテゴリを選択でき、選択したカテゴリが青色で表示されるとします。カテゴリ名が長いものがあったり、端末サイズが小さいものであった場合、表示しきれません。

端末サイズがiPhoneSEの場合は下記の画像のよう表示となります。

f:id:fjswkun:20200710160343p:plain

表示しきれない場合にフォントを小さくしたり、表示しきれない部分を...とするなどの方法はありますが、表示しきれない場合は折り返して表示するような実装を考えました。

結果として以下のようにします。

iPhone11の場合 f:id:fjswkun:20200710161016p:plain

iPhoneSEの場合 f:id:fjswkun:20200710161029p:plain

実装方法

表示しきれない場合に折り返して表示することはUICollectionViewで実現できます。ただし折り返してセルが表示される場合、セルの表示位置やセル間のスペースが期待通りにならないため、UICollectionViewFlowLayoutのサブクラスを作成し、実装する必要があります。

実装したUICollectionViewFlowLayoutは下記のとおりです。

import UIKit

class CustomCollectionViewFlowLayout: UICollectionViewFlowLayout {
    let cellSpacing: CGFloat = 28
 
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        self.minimumLineSpacing = 5
        self.sectionInset = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10)
        let attributes = super.layoutAttributesForElements(in: rect)
        
        
        var leftMargin = sectionInset.left
        var maxY: CGFloat = 0
        attributes?.forEach { layoutAttribute in
            if layoutAttribute.frame.origin.y >= maxY {
                leftMargin = sectionInset.left
            }
            layoutAttribute.frame.origin.x = leftMargin
            leftMargin += layoutAttribute.frame.width + cellSpacing
            maxY = max(layoutAttribute.frame.maxY, maxY)
        }
        return attributes
    }
}

これをcollectionViewのcollectionViewLayoutプロパティに設定すればよい。

実装したサンプルはこちらにアップしています。 https://github.com/fuji2013/SampleNewlineView github.com