こんにちは!iOSアプリを開発している澁谷太智です!
業務内で、楽曲プレイヤー画面を作成していました。
下記のような要件の画面を作成していました。
- 縦スクロールで楽曲プレイヤーを閉じることができる
- 画面上部に、横スクロールで曲送り戻しのできるジャケット写真を表示する
- 再生位置を表示・調整できるスライダーを表示する
この画面を作成する際に、縦スクロール/横スクロールを SwiftUI の Gesture を用いて実装しました。
この2つの Gesture を共存させるのに苦労しました。
今回はその方法について解説していこうと思います!
ニッチな内容かとは思いますが、お付き合いください!
目次
下準備
まず、下のGIFのように動作するViewを作成します。
View構成
最下層から順に下記のようになっています。
- ZStack
- Rectangle(色:indigo)
- VStack
- HorizontalMoveView
- Rectangle(色:orange)
- HorizontalMoveView
- Rectangle(色:mint)
コード
親View
- ZStack を画面いっぱいに作成
- 適当な大きさの Rectangle() を ZStack 内に配置
- Rectangle の上に VStack を配置
- VStack 内に後ほど作成する HorizontalMoveView() を大きさ違いで2つ作成
- 縦方向にドラッグできるジェスチャーを作成
- ZStack に対して作成したジェスチャーを設定
ContentView.swift
struct ContentView: View { @State private var zStackYOffset: CGFloat = .zero @State private var lastYOffset: CGFloat = .zero var body: some View { let dragGesture = DragGesture() .onChanged { value in zStackYOffset = lastYOffset + value.translation.height } .onEnded { value in zStackYOffset = lastYOffset + value.translation.height lastYOffset = zStackYOffset } ZStack() { Rectangle() .fill(.indigo) .frame(width: .infinity, height: 600) VStack { HorizontalMoveView(color: .orange) .frame(width: 300, height: 300) HorizontalMoveView(color: .mint) .frame(width: 100, height: 100) } } .offset(y: zStackYOffset) // ドラッグに応じてY軸のOffsetが変化する .gesture(dragGesture) // ジェスチャーを設定 .frame(maxWidth: .infinity, maxHeight: .infinity) } } |
子View(HorizontalMoveView)
- 適当な大きさの Rectangle() を作成する
- 横方向にドラッグできるジェスチャーを Rectangle に対して設定する
HorizontalMoveView.swift
struct HorizontalMoveView: View { /// RectangleのX軸Offset @State private var rectXOffset: CGFloat = .zero /// Dragした後の最終的なOffset @State private var lastXOffset: CGFloat = .zero /// Rectangleの色 private let color: Color init(color: Color) { self.color = color } var body: some View { Rectangle() .fill(color) .offset(x: rectXOffset) // ジェスチャーを設定 .gesture( // ドラッグに応じてX軸のOffsetが変化する DragGesture() .onChanged { value in rectXOffset = lastXOffset + value.translation.width } .onEnded { value in rectXOffset = lastXOffset + value.translation.width lastXOffset = rectXOffset } ) .border(.cyan) } } |
問題
触ってみるとわかるのですが、親Viewの縦方向の DragGesture が子View上では動作しません。
今回の期待値である、
- 全ての子Viewで親Viewの Gesture と共に動作させる
- 特定の子Viewでのみ親Viewの Gesture と共に動作させる
が満たせていないことがわかります。
作成した画面の動き | 期待値 1 | 期待値 2 |
---|---|---|
子Viewで親ViewのGestureが発火しない | 全ての子Viewで親ViewのGestureが発火する | 特定の子Viewのみ親ViewのGestureが発火する |
作成したコードを改修していきます。
解決策
全ての子Viewで親ViewのGestureと共に動作させる
下記1点の変更を加えることで解決します。
- ZStack に設定していた、 .gesture() を .simultaneousGesture() へ変更する
ContentView.swift
struct ContentView: View { @State private var zStackYOffset: CGFloat = .zero @State private var lastYOffset: CGFloat = .zero var body: some View { let dragGesture = DragGesture() .onChanged { value in zStackYOffset = lastYOffset + value.translation.height } .onEnded { value in zStackYOffset = lastYOffset + value.translation.height lastYOffset = zStackYOffset } ZStack() { Rectangle() .fill(.indigo) .frame(width: .infinity, height: 600) VStack { HorizontalMoveView(color: .orange) .frame(width: 300, height: 300) HorizontalMoveView(color: .mint) .frame(width: 100, height: 100) } } .offset(y: zStackYOffset) .simultaneousGesture(dragGesture) // .simultaneousGesture()でジェスチャーを設定 .frame(maxWidth: .infinity, maxHeight: .infinity) } } |
特定の子Viewでのみ親ViewのGestureと共に動作させる
下記2点の変更を加えることで解決します。
- 親View内の HorizontalMoveView() に対して、 .simultaneousGesture() で親Viewの Gesture を設定する
- 親Viewの Gesture のイニシャライズを、 DragGesture(coordinateSpace: .global) に変更する
ContentView.swift
struct ContentView: View { @State private var zStackYOffset: CGFloat = .zero @State private var lastYOffset: CGFloat = .zero var body: some View { let dragGesture = DragGesture(coordinateSpace: .global) // coordinateSpaceを.global に設定 .onChanged { value in zStackYOffset = lastYOffset + value.translation.height } .onEnded { value in zStackYOffset = lastYOffset + value.translation.height lastYOffset = zStackYOffset } ZStack() { Rectangle() .fill(.indigo) .frame(width: .infinity, height: 600) VStack { HorizontalMoveView(color: .orange) .frame(width: 300, height: 300) .simultaneousGesture(dragGesture) // .simultaneousGesture()でジェスチャーを設定 HorizontalMoveView(color: .mint) .frame(width: 100, height: 100) } } .offset(y: zStackYOffset) .gesture(dragGesture) .frame(maxWidth: .infinity, maxHeight: .infinity) } } |
coordinateSpace のデフォルト値は .local です。
local のまま HorizontalMoveView に対して Gesture を設定すると、HorizontalMoveView の 座標を基準として計算を行なってしまいます。親Viewと同じ計算座標にしたいので、 .global で画面全体の座標を基準として計算させるようにしています。
公式ドキュメント:CoordinateSpace | Apple Developer Documentation
.simultaneousGesture()
複数のジェスチャーを同時に認識させることができるモディファイア。
公式ドキュメント:simultaneousGesture(_:including:) | Apple Developer Documentation
最後に
いかがだったでしょうか。
そもそも今回の場合、DragGesture と ScrollView(.horizontal) を利用すれば解決なのでは?となるかと思います。
ScrollView の持っている機能だけで開発要件を満たせるのであれば、ScrollView を利用する形で問題ないと思います。(ジェスチャー方向の制御等複雑な処理を書かなくて済むので)
しかしながら、機能要件によっては ScrollView の持っている機能だけでは制御しきれない場合もあります。
そのような場合に、DragGesture を利用して Offset を調節して ScrollView のように見せる実装が一つの手段として考えられます。同じ状況に置かれた方の助けになればと思います。
最後まで読んでいただき、ありがとうございました!
また別の記事でお会いしましょう。
この記事を書いた人
最近書いた記事
- 2024.09.30【SwiftUI】Gestureで縦横スクロールを共存させる
- 2024.03.29【SwiftUI】iOS 15でリフレッシュ可能なScrollView を作る - RefreshableScrollView -
- 2024.03.29有線イヤホンをワイヤレスにしてみよう
- 2024.03.08イヤホンプラグの種類って?2024年版