なつねこメモ

主にプログラミング関連のメモ帳 ♪(✿╹ヮ╹)ノ 書いてあるコードは自己責任でご自由にどうぞ。記事本文の無断転載は禁止です。

斜めドラッグをどう扱う?SwiftUI で自然なタイムラインを作る

SwiftUI を使って Twitter や Instagram のようなアプリを作っていると、 だいたい一度はこんな UI を作りたくなります。

  • 全体はタイムライン形式で縦スクロール
  • 各投稿には複数枚の写真があって、横にスワイプすると切り替わる

よくある UI ですね。 ただ、これを SwiftUI だけで自然に実装しようとすると、単純な実装だと難しかったです。
問題になるのは「斜めのドラッグ」で、例えば、次のような挙動をさせたいケースです。

上方向へのスクロールと見なせる場合はタイムラインスクロールを、水平方向へのスクロールはスワイプとして扱いたい

ユーザーが完全に横にスワイプしているなら写真切り替え、でも 30度くらい上斜めに指を動かしているなら、タイムラインがスクロールしてほしいというケース。 この方が、体感としてはかなり自然で、ストレスの少ない UI/UX を実現できると思うんですが、これが難しい。

SwiftUI の DragGesture だけだと厳しい

まずは、よくある SwiftUI だけの実装を見てみます。

.highPriorityGesture(
  DragGesture(minimumDistance: 30)
)

親は ScrollView + LazyVStack、子(カルーセル)に highPriorityGesture を付けている、という構成です。

これで、見た目は問題なさそうなんですが、子のジェスチャが優先されすぎ、少しでも横成分があると「横スワイプ」と判定されやすくなります。 結果として、ほぼ完全に縦じゃないとタイムラインが動かないという挙動になります。

iOS Simulator とかだと意外となんともないのですが、実機で実際に触ると、なんかスクロールしづらいな……という感触になります。

解決の方針:最初に“方向”を決めてしまう

ここで AI に助けを求め辿り着いたのが、UIPanGestureRecognizer 経由で「タッチ開始直後の移動量を見て、縦成分が強ければ横ジェスチャを .failed にする」 というアプローチでした。 こうすることで、横ジェスチャが失敗した瞬間に ScrollView 側にタッチイベントが流れ、スムーズな縦スクロールが実現できます。

UIKit を使った横パン専用ジェスチャ

やっていることはシンプルです。

  1. タッチ開始位置を記録 (touchesBegan)
  2. 少し動いたら、縦と横の移動量を比較 (touchesMoved)
  3. 縦成分が強ければ、この横パンは .failed として記録 (touchesMoved)

そうすると、ScrollView 側がそのまま縦スクロールを処理してくれるようになります。 コードは次のようになります:

private class HorizontalPanGestureRecognizer: UIPanGestureRecognizer {
  private var initialTouchPoint: CGPoint?
  private var hasDecidedDirection = false

  override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
    super.touchesBegan(touches, with: event)
    initialTouchPoint = touches.first?.location(in: view)
    hasDecidedDirection = false
  }

  override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
    super.touchesMoved(touches, with: event)

    guard
      !hasDecidedDirection,
      let initialPoint = initialTouchPoint,
      let currentPoint = touches.first?.location(in: view)
    else { return }

    let deltaX = abs(currentPoint.x - initialPoint.x)
    let deltaY = abs(currentPoint.y - initialPoint.y)

    // 少しは動かないと判定しない
    let threshold: CGFloat = 10
    if deltaX > threshold || deltaY > threshold {
      hasDecidedDirection = true

      // 縦成分が優勢なら、この横ジェスチャは失敗させる
      // 1.7 ≒ tan(60°) なので、だいたい上方向左右30度くらいまで縦として許容
      if deltaY > deltaX / 1.7 {
        state = .failed
      }
    }
  }

  override func reset() {
    super.reset()
    initialTouchPoint = nil
    hasDecidedDirection = false
  }
}

ポイントは 1.7 という係数です。 これを調整することで「どこまでを縦スクロールとして許容するか」をチューニングできます。 個人的には 1.7 くらいが、Instagram などの操作感に近く、より自然な操作としてしっくりきました。

SwiftUI への組み込み

このジェスチャを SwiftUI から使うために、UIViewRepresentable でラップします。

private struct HorizontalPanGestureView: UIViewRepresentable {
  let onChanged: (CGFloat) -> Void
  let onEnded: (CGFloat, CGFloat) -> Void

  func makeUIView(context: Context) -> UIView {
    let view = UIView()
    view.backgroundColor = .clear

    let panGesture = HorizontalPanGestureRecognizer(
      target: context.coordinator,
      action: #selector(Coordinator.handlePan(_:))
    )
    panGesture.delegate = context.coordinator
    view.addGestureRecognizer(panGesture)

    return view
  }

  func updateUIView(_ uiView: UIView, context: Context) {
    context.coordinator.onChanged = onChanged
    context.coordinator.onEnded = onEnded
  }

  func makeCoordinator() -> Coordinator {
    Coordinator(onChanged: onChanged, onEnded: onEnded)
  }

  class Coordinator: NSObject, UIGestureRecognizerDelegate {
    // ...略...

    @objc func handlePan(_ gesture: UIPanGestureRecognizer) {
      let translation = gesture.translation(in: gesture.view)
      let velocity = gesture.velocity(in: gesture.view)

      switch gesture.state {
      case .changed:
        onChanged(translation.x)
      case .ended, .cancelled:
        onEnded(translation.x, velocity.x)
      default:
        break
      }
    }    

    // ScrollView との共存を優先するなら true にしておくと安定しやすい
    // 今回は Carousel で横スクロール中に ScrollView が縦に動いてほしくないので false
    func gestureRecognizer(
      _ gestureRecognizer: UIGestureRecognizer,
      shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
    ) -> Bool {
      return false
    }
  }
}

そして、それを overlay に乗せるだけです。

.overlay(
  HorizontalPanGestureView(
    onChanged: { translation in
      self.dragOffset = translation
    },
    onEnded: { translation, velocity in
      // ページ切り替え判定
    }
  )
)

大事なのは、

  • 横として成立しなかった場合は .failed になる
  • その場合、ScrollView の縦スクロールがそのまま生きる

という点です。 結果として、横にスッと動かせば写真スワイプ、少し斜めなら、そのままタイムラインスクロールという かなり自然な挙動になります。

まとめ

SwiftUI は便利ですが、ジェスチャの競合解決や細かい挙動の調整は UIKit での実装が必要ということがわかりました。 無理に SwiftUI だけで頑張って微妙な挙動になるより、ピンポイントで AI に助けを求めつつ UIKit を混ぜたほうが、結果的にコードもスッキリするしユーザー体験も良くなるな、という話でした。


この記事ははてなエンジニア Advent Calendar 2025 35日目の記事でした。がんばった。