はじめに
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()
}
}
様々な実装パターンが考えられますが、上記のような処理でモーダル遷移が実現できます。仕組みとしては、まず画面遷移を行うかどうかを示すフラグを宣言しておき、 Button
や TapGesture
によるアクションキャッチ時にフラグを書き換えてあげます。そして、そのフラグの変化を検知した 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)
}
}
これが実装例になります。 UIProgressView
は progress
の値を変化させてあげなければいけません。そのため、単純に 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
さいごに
説明間違えやアドバイス、ご指摘などありましたら遠慮なくコメントいただけると嬉しいです!
最後まで読んでいただき、ありがとうございます!!