iOS, SwiftUI, Swift

[SwiftUI]選択可能なUIコンポーネントの作り方【複数選択偏】

人はときにタップをしたらそのビューを選択状態にするコンポーネントが必要になることがあります。
ちょっと手こずったのでブログに作り方を残します。
今回は複数選択する場合を解説したいと思います。

実行環境

  • Xcode 11.5
  • iOS 13.5

複数選択可能なUIコンポーネント

HTMLだとチェックボックスがいい例ですが、複数選択できるコンポーネントがあると素敵ですよね。
作ってみましょう。

Jul-12-2020-00-57-05

赤、緑、青のViewがあって、タップするとViewに黒枠が追加されて選択状態がわかるようになります。
またコンソールにもタップされたら「red is selected」 。もう一度タップすると「red is not selected」というようなメッセージができるようにしています。

ViewModelの定義

まずはデータまわりを定義していきましょう。

MultipleSelectableViewModelというクラスを定義します。

final class MultipleSelectableViewModel: ObservableObject {
    @Published var isSelectedRed: Bool = false
    @Published var isSelectedGreen: Bool = false
    @Published var isSelectedBlue: Bool = false

    var cancels: [Cancellable] = []

    init() {
        let redSubscriber = $isSelectedRed.sink { (selected) in
            print("red is\(selected ? "" : " not") selected")
        }
        cancels.append(redSubscriber)

        let greenSubscriber = $isSelectedGreen.sink { (selected) in
            print("green is\(selected ? "" : " not") selected")
        }
        cancels.append(greenSubscriber)

        let blueSubscriber = $isSelectedBlue.sink { (selected) in
            print("blue is\(selected ? "" : " not") selected")
        }
        cancels.append(blueSubscriber)
    }
}

単純に赤、緑、青それぞれのisSelectedフラグを定義して、イニシャライザでそれぞれのフラグが変更されたらprint文を出しているだけです。

一応解説。

let redSubscriber = $isSelectedRed.sink { (selected) in
    print("red is\(selected ? "" : " not") selected")
}
cancels.append(redSubscriber)

$isSelectedRedと$をつけると@PublishedのProperty WrapperのプロパティはPublisherとしてみなされます。isSelectedRedが更新されるとPublisherのイベントが通知されます。
sinkはPublisherからのイベントを受け取るとクロージャーでその値を扱うことができるsubscriberです。ここではフラグがtrueかfalseによって文字を出し分けています。
最後にcancels.append(redSubscriber)でsubscriberを保持します。

Viewの定義

struct MultipleSelectableView: View {
    @ObservedObject var viewModel = MultipleSelectableViewModel()
    var body: some View {
        ScrollView {
            Rectangle()
                .foregroundColor(Color.red)
            .frame(width: 100, height: 100)
            .cornerRadius(10)
                .onTapGesture {
                    self.viewModel.isSelectedRed.toggle()
            }
            .padding()
            .border(Color.black, width: self.viewModel.isSelectedRed ? 4 : 0)

            Rectangle()
                .foregroundColor(Color.green)
            .frame(width: 100, height: 100)
            .cornerRadius(10)
                .onTapGesture {
                    self.viewModel.isSelectedGreen.toggle()
            }
            .padding()
            .border(Color.black, width: self.viewModel.isSelectedGreen ? 4 : 0)

            Rectangle()
                .foregroundColor(Color.blue)
            .frame(width: 100, height: 100)
            .cornerRadius(10)
                .onTapGesture {
                    self.viewModel.isSelectedBlue.toggle()
            }
            .padding()
            .border(Color.black, width: self.viewModel.isSelectedBlue ? 4 : 0)
        }
    }
}

ちょっと長いですがやっていることはViewModelを作って、赤、青、緑のそれぞれのViewに適切なフラグをバインドさせているだけです。

@ObservedObject var viewModel = MultipleSelectableViewModel()

@ObservedObjectのProperty WrapperをつけてviewModelを定義します。これでMultipleSelectableViewModel@Publishedプロパティを変更するとSwiftUIがその変化を受け取れるようになります。

Rectangle()
    .foregroundColor(Color.red)
.frame(width: 100, height: 100)
.cornerRadius(10)
    .onTapGesture {
        self.viewModel.isSelectedRed.toggle()
}
.padding()
.border(Color.black, width: self.$viewModel.isSelectedRed.wrappedValue ? 4 : 0)

赤い矩形Viewの定義です。青と緑も同じような実装をしています。
Rectangle()で矩形を作り.foregroundColor(Color.red)で色を設定しています。

.onTapGesture {
    self.viewModel.isSelectedRed.toggle()
}

タップしたらviewModelのisSelectedRedフラグを切り替えるようにしています。

選択されたら黒い枠線がでてくるのはこのコードです。

.border(Color.black, width: self.viewModel.isSelectedRed ? 4 : 0)

isSelectedRedフラグは読み取りする場合は$をつけなくて大丈夫です。
三項演算子でtrueならborder:4をつけています。

リファクタリング

これでOKなのですが、色を変えるだけなのに3つの矩形をそれぞれ定義するのはよくない実装ですね。重複がけっこうあります。
リファクタリングしましょう。

Rectangleにカーソルをあわせて⌘+クリックをするとメニューがでてきます。

----------2020-07-12-1.12.25

Extract Subviewをクリックすると選択したViewが切り出されます。

----------2020-07-12-1.26.31

SwiftUIではこのようにUIコンポーネントを切り出すことができます。Appleも推奨しているやり方です。Viewが多くなっても処理への影響はわずかなのでどんどん切り出しましょう。

切り出した直後は依存関係エラーがでているので直しましょう。

struct SelectBox: View {
    @Binding var isSelected: Bool
    let color: Color
    var body: some View {
        Rectangle()
            .foregroundColor(color)
            .frame(width: 100, height: 100)
            .cornerRadius(10)
            .onTapGesture {
                self.isSelected.toggle()
        }
        .padding()
        .border(Color.black, width: self.isSelected ? 4 : 0)
    }
}

名前をSelectBoxにし、@BindingisSelectedフラグとcolorプロパティを定義します。
それらをViewの定義で依存している箇所に当てはめていきます。

最後に親ビューになったMultipleSelectableViewSelectBoxを適応していきます。

struct MultipleSelectableView: View {
    @ObservedObject var viewModel = MultipleSelectableViewModel()
    var body: some View {
        ScrollView {
            SelectBox(isSelected: $viewModel.isSelectedRed, color: .red)
            SelectBox(isSelected: $viewModel.isSelectedGreen, color: .green)
            SelectBox(isSelected: $viewModel.isSelectedBlue, color: .blue)
        }
    }
}

だいぶすっきりしてきましたね。

まとめ

複数選択する場合のUIコンポーネントの実装例を解説しました。
参考になれば幸いです。

Code

今日紹介したコードはGistに上げました。
コード全体を確認したい場合はこちらを御覧ください。

https://gist.github.com/SatoTakeshiX/037f1a4b170aed651e19bcb4795bd031

宣伝

SwiftUIでアプリを作り方を解説した「1人でアプリを作る人を支えるSwiftUI開発レシピ」がBOOTHで発売中です。
SwiftUIでアプリを作りたい方、ぜひチェックしてください!現状iOS 13向けの内容ですが、今後iOS 14にも対応する予定です。

https://personal-factory.booth.pm/items/1920812



Author image

About 佐藤 剛士

  • Japan Tokyo