SwiftUI & Combine の調べたことメモ

iOS13の正式リリースが間近ですね。 そろそろ話題のSwiftUIやCombine frameworkにキャッチアップしないと、と思い徐々に触り始めています。 その際に「これどう書くんだろう?」と気になったことを雑にメモしておきます。

また、以下のコードはXcode11 beta6で動作を確認しています。

相対的なサイズを指定してレイアウトする

Viewのサイズを指定する場合には以下の frame でサイズを指定します。

Text("Hello World").frame(width: 100, height: 200)

しかし、このように特定のサイズではなく相対的なサイズを指定したい時があります。 その際には GeometryReader が使えます。

struct ContentView: View {
    var body: some View {
        GeometryReader { geometry in
            VStack {
                // Textの高さを 2:3 で振り分ける
                Text("Hello")
                    .frame(width: geometry.size.width,
                           height: geometry.size.height / 5 * 2)
                    .background(Color.blue)
                Text("World")
                    .frame(width: geometry.size.width,
                           height: geometry.size.height / 5 * 3)
                    .background(Color.green)
            }
        }
    }
}

Listでページネーション

複数ページのデータを取得する場合、UIKitのUITableViewであれば、スクロール量や終端付近のセルが表示されるタイミングで次ページのデータを取得すると思います。 SwiftUIのListでは、Listの各要素のonAppearでその処理をさせるのが手っ取り早そうでした。

struct ContentView: View {
    @ObservedObject private var viewModel = ViewModel()
    
    var body: some View {
        List {
            ForEach(self.viewModel.values, id: \.self) { value in
                HStack {
                    Text("Value:")
                    Text("\(value)")
                }
                .onAppear {
                    // リストの要素が表示されるときに、それが最後の要素であれば次のページを取得する
                    if self.viewModel.values.last == value {
                        print("Fetch next")
                        self.viewModel.fetchNextPage()
                    }
                }
            }
            
        }
        .onAppear {
            // Listが表示されるときに最初のページを取得する
            self.viewModel.fetchFirstPage()
        }
    }
}

Listの要素を編集

要素を削除

onDeleteを設定するだけで、要素をスワイプして削除できるようになります。

struct ContentView: View {
    @State private var fruits = [
        "apple",
        "banana",
        "strawberry",
        "grape",
        "lemon",
    ]
    
    var body: some View {
        List {
            ForEach(fruits, id: \.self) { fruit in
                Text(fruit)
            }
            // onDeleteがあると、セルをスワイプで削除できるようになる
            .onDelete { (indexSet) in
                // セル削除の処理を書く
                self.fruits.remove(atOffsets: indexSet)
            }
        }
    }
}

要素を移動

削除と同様に、移動をさせるためにはonMoveを設定します。 編集モードに切り替える必要があるので、ナビゲーションバーにEditButtonを追加しています。

struct ContentView: View {
    @State private var fruits = [
        "apple",
        "banana",
        "strawberry",
        "grape",
        "lemon",
    ]
    
    var body: some View {
        NavigationView {
            List {
                ForEach(fruits, id: \.self) { fruit in
                    Text(fruit)
                }
                .onMove { (fromOffsets, toOffset) in
                    // セル移動の処理を書く
                    self.fruits.move(fromOffsets: fromOffsets, toOffset: toOffset)
                }
            }
            // NavigationBarに編集ボタンを追加。タップでセルを移動できるようになる。
            .navigationBarItems(trailing: EditButton())
        }
    }
}

Publisherのデバッグにprint(_:to:)が便利

Publisherがどんな値を流しているかを確認するのにprintを1行差し挟むだけなのでお手軽です。

NotificationCenter.default
            .publisher(for: Notification.Name("Hello"))
            .print("テスト")   // これを入れるだけ
            .filter { $0.object != nil }
            .map { $0.object as! String }
            .assign(to: \.value, on: self)
            .store(in: &cancellables)

// テスト: receive subscription: (NotificationCenter Observer)
// テスト: request unlimited
// テスト: receive value: (name = Hello, object = Optional(world), userInfo = nil)

既存の非同期処理をPublisherに変換

非同期にAPIを叩くような処理では、結果をコールバックで受け取ることは多いかと思います。

func someAPI(completion: @escaping (String) -> Void) {
    // ... なんかいろいろやる
    completion("Hello world")  // 終わったらコールバックで結果を返す
}

これをPublisherにしたいときはFutureでラップします。

func someAPIPublisher() -> AnyPublisher<String, Never> {
        return Future<String, Never> { (promise) in
            someAPI { (value) in
                promise(.success(value))
            }
        }
        .eraseToAnyPublisher()
    }

で、これを使うとこんな感じに書けます。

someAPIPublisher()
            .assign(to: \.value, on: self)
            .store(in: &cancellables)