Help us understand the problem. What is going on with this article?

SwiftUI 実践 tips 集

はじめに

MIHO というキャラボイスアプリを SwiftUI と Combine を用いて開発しました。互換性の欄が iOS13 以降となっていることが確認できるかと思います(証拠とは言えませんが)。
このアプリを開発するにあたって学んだことをここにまとめたいと思います。

TapGesture & ContentShape

List を用いた UI で、セルをタップすると別の View に遷移する処理は公式のチュートリアルでも確認できます。非常にシンプルです。
では、「セルをタップしたときに音声を再生させる」のように、セルをタップしたときに独自の処理を走らせる場合はどうしたら良いでしょうか。解決方法としては Button を使う方法と onTapGesture を使う方法が考えられます。
今回は onTapGesture を使う場合を紹介します。

List(VoiceModel.allCases) { item in
    VoiceRowView(voice: item)
        .onTapGesture {
            viewModel.action()
        }
}

使い方は上記の通り、非常にシンプルです。

struct VoiceRowView: View {
    var body: some View {
        HStack(spacing: 8) {
            Image("image") 
            Text("Apple")
            Spacer()
        }
    }
}

次に、そのセルのレイアウトが上記のように Spacer を用いた実装になっていたとします。
この場合、左から画像・文字列と並び、右側には空白ができることになります(もちろん文字数が多い場合は右端まで埋まる)。ここで勘がいい方は気がつくかもしれませんが、この右側の空白部分ではタップ反応を受け取ることができません。

List(VoiceModel.allCases) { item in
    VoiceRowView(voice: item)
        .contentShape(Rectangle())
        .onTapGesture {
            self.playView.viewModel.prepare(item: item)
        }
}

onTapGesture を使うという条件下で、この問題の解決方法は上記のようになります。 .contentShape(Rectangle()) と指定することでセルの形を長方形であると定義します。こうすることによって空白があったとしてもセル全体でアクションを受け取ることが可能になります。

参考文献:
How to read tap and double-tap gestures
SwiftUI can't tap in Spacer of HStack

GeometryReader

View のサイズを画面サイズから計算して指定したい場合があると思います。今回は、 16:9 の View を作成する例とともにご紹介します。

var body: some View {
    GeometryReader { geometry in
        AVPlayerView(bundleDataName: "header")
            .frame(width: geometry.size.width, height: (9 * geometry.size.width) / 16)
    }
}

上記が UI 設計例になります。 GeometryReader を用いることで その View の width と height を取得することが可能です。 ただ、これが端末の画面サイズを示すものではないことに注意してください(Safe Area のことも意識してあげてくださいね)。

参考文献:
How to provide relative sizes using GeometryReader

Sheet & Alert

画面遷移でプッシュ遷移は List を用いることでシンプルに実装が可能です。もう一つの遷移方法として存在する「モーダル遷移」を実現できるのが sheet です。ここでは、先程も説明した TapGesture とともに実装する例でご紹介します。

@State private var isShowDetail: Bool = false

var body: some View {
    GeometryReader { geometry in
        ...
    }
    .onTapGesture {
        self.isShowDetail = true
    }
    .sheet(isPresented: $isShowDetail) {
        VoiceDetailView()
    }
}

様々な実装パターンが考えられますが、上記のような処理でモーダル遷移が実現できます。仕組みとしては、まず画面遷移を行うかどうかを示すフラグを宣言しておき、 ButtonTapGesture によるアクションキャッチ時にフラグを書き換えてあげます。そして、そのフラグの変化を検知した sheet が画面遷移を行うといった動きになります。

@State private var isShowAlert: Bool = false

var body: some View {
    GeometryReader { geometry in
        ...
    }
    .onTapGesture {
        self.isShowAlert = true
    }
    .alert(isPresented: $isShowAlert) {
        Alert(
            title: Text("Title"),
            message: Text("message"),
            dismissButton: .default(Text("OK"))
        )
    }
}

ついでに Alert の表示方法も同じ考え方なので載せておきます。非常にシンプルですね。

NavigationViewStyle

List を用いて iPad 対応する場合に気にしないといけないことに Style の存在があります。特に条件を指定しない場合、iPad で List は設定画面のような表示になります。左側に項目一覧、そして右側に詳細画面となるおなじみの UI です。
セルをタップして詳細画面に遷移するような場合は、この標準 UI に適応させればいいのですが、今回はセルのタップで画面遷移は発生しないので、 iPad でも iPhone と同様の UI で表示しなければなりません。

ここで登場するのが navigationViewStyle() です。

NavigationView {
    List(VoiceModel.allCases) { item in
        ...                    
    }
}
.navigationViewStyle(StackNavigationViewStyle())

このように navigationViewStyle()StackNavigationViewStyle() を指定することで、 iPad でもリストが画面全面に表示されるようになります。

*Representable

私の GitHub や Qiita 記事、勉強会資料などで何度も登場してきた *Representable ですが、今回はちょっと実践的な内容となります。
今回は SwiftUI にはないパーツである UIProgressView を SwiftUI に対応させる例でご紹介します。

struct ProgressView: UIViewRepresentable {
    @Binding var progress: Float
    var progressTintColor: UIColor

    func makeUIView(context: Context) -> UIProgressView {
        return UIProgressView().apply { this in
            this.progressTintColor = progressTintColor
        }
    }

    func updateUIView(_ uiView: UIProgressView, context: Context) {
        uiView.setProgress(progress, animated: true)
    }
}

これが実装例になります。 UIProgressViewprogress の値を変化させてあげなければいけません。そのため、単純に UIViewRepresentable に準拠させてあげるだけでは動作してくれません。
そこで、 @Binding を用いて外部の値変化を監視できるようにしています。

#if DEBUG
struct ProgressViewPreviews: PreviewProvider {
    static var previews: some View {
        ProgressView(progress: .constant(0.5), progressTintColor: .blue)
            .previewLayout(.fixed(width: 300, height: 10))
    }
}
#endif

ただ、先程作成した ProgressView をプレビュー可能にするとき、困ることがあります。 @Binding var progress: Float に渡す初期値です。対応方法は上記実装例にもあるように .constant() に値をセットしたものを指定するという感じになります。

参考文献:
How to use UIKit in SwiftUI
SwiftUI @Binding Initialize

さいごに

説明間違えやアドバイス、ご指摘などありましたら遠慮なくコメントいただけると嬉しいです!
最後まで読んでいただき、ありがとうございます!!

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした