👏

[SwiftUI] SwiftUIでタップと長押しを区別する方法

2024/12/23に公開

概要

SwiftUI には、タップイベントを取得するonTapGestureTapGesture、長押しイベントを取得するonLongPressGestureLongPressGestureが存在します。
しかし投稿時点では、私が調査した限り一つの View のタップと長押しを区別できるような API は存在していません。本稿では、試みたが NG となった案と、最終的に解決することができた方法をお伝えします。

環境

  • iOS17, iOS18
  • Xcode16.1

NG 案 ①

ButtonStyleConfigurationisPressedを使用することで、「ボタンが押されたこと」と「離されたこと」を検知することができます。

struct DetectPressingButtonStyle: ButtonStyle {
  let onPressingGesture: (Bool) -> Void

  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .scaleEffect(configuration.isPressed ? 0.9 : 1)
      .animation(.easeIn(duration: 0.15), value: configuration.isPressed)
      .onChange(of: configuration.isPressed) { _, new in
        // ボタンが押されているかどうかを判別可能
        onPressingGesture(new)
      }
  }
}

extension ButtonStyle where Self == DetectPressingButtonStyle {
  static func detectPressing(onPressingGesture: @escaping (Bool) -> Void) -> DetectPressingButtonStyle {
    DetectPressingButtonStyle(onPressingGesture: onPressingGesture)
  }
}

struct FirstRejection: View {
  @State private var isPressed = false
  var body: some View {
    VStack {
      Button {

      } label: {
        Text("Button")
          .font(.system(size: 20, weight: .bold))
          .foregroundStyle(.white)
          .padding(.vertical, 10)
          .padding(.horizontal, 20)
          .background(.blue)
          .clipShape(RoundedRectangle(cornerRadius: 8))
      }
      .buttonStyle(.detectPressing { isPressed in
        self.isPressed = isPressed
      })

      Text("isPressed: \(isPressed)")
    }
  }
}

しかしこれでは、タップしたときもisPressedの状態は変化し、また長押し開始時・終了時もisPressedの状態は変化します。そのため、これではタップと長押しを区別することはできません。

NG 案 ②

onTapGestureonLongPressGestureonPressingChangedを組み合わせることで、タップと長押しを判定することはできそうです。

struct SecondRejection: View {
  @State private var isTapped = false
  @State private var isLongPressed = false

  var body: some View {
    VStack {
      Text("Button")
        .font(.system(size: 20, weight: .bold))
        .foregroundStyle(.white)
        .padding(.vertical, 10)
        .padding(.horizontal, 20)
        .background(.blue)
        .clipShape(RoundedRectangle(cornerRadius: 8))
        .scaleEffect(isLongPressed ? 0.9 : 1)
        .opacity(isTapped ? 0.8 : 1)
        .animation(.easeInOut(duration: 0.15), value: isLongPressed)
        .animation(.easeInOut(duration: 0.15), value: isTapped)
        .onTapGesture {
          Task {
            isTapped = true
            // onTapGestureではボタンが離されたタイミングは検知できないため、擬似的に再現している
            try? await Task.sleep(for: .seconds(0.1))
            isTapped = false
          }
        }
        .onLongPressGesture(minimumDuration: 0.1) {
          isLongPressed = true
        } onPressingChanged: { _isPressed in
          // iOS18では指を離したときにisPressedがfalseで返ってくるが、iOS17では異なる挙動となる
          guard !isPressed else { return }
          if isPressed {
            isLongPressed = false
          }
        }

      Text("isTapped: \(isTapped)")
      Text("isLongPressed: \(isLongPressed)")
    }
  }
}

しかしこれには、以下の2つの問題点があります。

ロングプレスと判定されるまで若干ラグがある

タップ判定と区別するために、onPressingChangedではisPressedtrueのときは無視しています。これはタップ時でもtrueが流れてきてしまうためです。そのため、performコールバックが実行されるまで少しラグがあり、その結果isLongPressedtrueになるまで時間がかかってしまいます。

iOS17 だと挙動が異なる

私の環境の iOS18.0 と iOS17.5 のシミュレータでonLongPressGestureの挙動を確認したところ、onPressingChangedisPressedfalseになるタイミングが OS 間で異なっていました。
iOS18 では、指を離したタイミングでisPressedfalseとなりました。
しかし iOS17 ではminimumDurationの時間が経過すると、ボタンを押したままにしていてもfalseが返ってきていました。そのため iOS17 以下をサポートするアプリでは、上記のロジックが使えなくなってしまいました。

iOS18 iOS17

解決できた案

最終的に以下のように複数のGestureを組み合わせることで、iOS17 でもタップと長押しを区別して判定することができました。

/// シングルタップと長押しのアクションを判定するmodifier
struct PressGestureModifier: ViewModifier {
  @State private var isLongPressed = false
  let minimumDuration: TimeInterval
  let perform: (Action) -> Void

  var tapGesture: some Gesture {
    TapGesture()
      .onEnded {
        // 長押しのアクションと競合する可能性があるためガードしている
        guard !isLongPressed else { return }
        perform(.tap)
      }
  }

  // 指を離したイベントを取得するために`dragGesture`を実装している
  var dragGesture: some Gesture {
    DragGesture(minimumDistance: 0)
      .onChanged { _ in }
      .onEnded { _ in
        if isLongPressed {
          perform(.longPress(isPressed: false))
          isLongPressed = false
        }
      }
  }

  // ※ポイント①
  var longPressGesture: some Gesture {
    LongPressGesture(minimumDuration: minimumDuration)
      .onChanged { _ in }
      .onEnded { _ in
        // `onEnded`は`minimumDuration`で指定した時間の経過後、コールバックされる
        isLongPressed = true
        perform(.longPress(isPressed: true))
      }
  }

  func body(content: Content) -> some View {
    content
      .gesture(
        longPressGesture // ※ポイント③
          .simultaneously(with: dragGesture) // ※ポイント②
          .simultaneously(with: tapGesture)
      )
  }
}

extension PressGestureModifier {
  enum Action {
    case tap
    case longPress(isPressed: Bool)
  }
}

extension View {
  func onPressGesture(
    minimumDuration: TimeInterval = 0.3,
    perform: @escaping (PressGestureModifier.Action) -> Void
  ) -> some View {
    self.modifier(PressGestureModifier(minimumDuration: minimumDuration, perform: perform))
  }
}


成功パターン

重要ポイント

上記の modifier を実装する際に注意した点を以下にまとめておきます。

ポイント ①: DragGestureの実装

DragGestureを実装している理由は、長押しアクションを終了する際の指を離したタイミングを取得するため です。既存の API では指を離したタイミングを取得する方法がありませんでした。(前述したように iOS18 でのonPressingChangedのみでは可能。)そこでDragGestureonEndedを使用することで、長押しアクションを終了するために指を離したというイベントを取得することができました。

simultaneously(with:)を使用

タップまたは長押しを開始した直後では、それがタップイベントなのか長押しイベントなのかは判定できません。そのため、TapGestureLongPressGestureは同時に認識できるようにしておく必要があります。ジェスチャを同時に認識するための方法として、simultaneousGesture(_:including:)simultaneously(with:)が挙げられます。

最初どっちでも一緒だと思ってsimultaneousGesture(_:including:)を使っていたのですが、自作したonPressGestureを以下のように複数箇所で利用すると、イベントが同時に発生してしまいました。

PressGestureModifier.swift
func body(content: Content) -> some View {
  // simultaneousGestureを使っているので、ここ以外のsimultaneousGestureも同時に認識されてしまう
  content
    .gesture(longPressGesture)
    .simultaneousGesture(dragGesture)
    .simultaneousGesture(tapGesture)
}
SuccessfulView.swift
struct SuccessfulView: View {
  @State private var isTapped = false
  @State private var isLongPressed = false

  var body: some View {
    VStack {
      Spacer()
      Text("Button")
        .font(.system(size: 20, weight: .bold))
        .foregroundStyle(.white)
        .padding(.vertical, 10)
        .padding(.horizontal, 20)
        .background(.blue)
        .clipShape(RoundedRectangle(cornerRadius: 8))
        .scaleEffect(isLongPressed ? 0.9 : 1)
        .opacity(isTapped ? 0.8 : 1)
        .animation(.easeInOut(duration: 0.15), value: isLongPressed)
        .animation(.easeInOut(duration: 0.15), value: isTapped)
        .onPressGesture(minimumDuration: 0.5) { action in  // ボタンにも`onPressGesture`を定義
          switch action {
          case .tap:
            isTapped = true
            Task {
              try? await Task.sleep(for: .seconds(0.1))
              isTapped = false
            }
          case .longPress(let isPressed):
            self.isLongPressed = isPressed
          }
        }

      Text("isTapped: \(isTapped)")
      Text("isLongPressed: \(isLongPressed)")
      Spacer()
    }
    .onPressGesture { action in // 背景(コンテナ)側にも`onPressGesture`を定義
      print("action: \(action)")
    }
    .frame(maxWidth: .infinity)
  }
}

しかしsimultaneously(with:)を利用した場合、上記イベントは発生しなくなりました。ドキュメントの説明を呼んでみると以下のような違いがあるようです。

  • simultaneousGesture(_:including:): 複数のジェスチャを同時に実行する
  • simultaneously(with:): 同時に実行させたいジェスチャを組み合わせて、新しいジェスチャを生成する

simultaneousGestureはその他のジェスチャも同時に実行するため、ボタンに割り当てたonPressGestureと背景に割り当てたonPressGestureが同時に実行されてしまっていました。

simultaneously(with:)を使用すると、TapGesture と LongPressGesture、DragGesture の3つのジェスチャは同時に認識するが、各onPressGestureは同時には認識されなくなるので、不具合が解決したのだと思います。

Gesture の定義順序

Gesture の定義順序も重要でした。以下のように TapGesture を先に定義してしまうと、LongPressGesture の minimumDuration で指定した時間が 0.2 TapGesture と LongPressGesture のイベントが同時発火してしまいました。この事象がなぜ発生しているか解明できてはいませんが、おそらく優先度の問題なのかもしれません。

PressGestureModifier.swift
func body(content: Content) -> some View {
  content
    .gesture(
      longPressGesture
        .simultaneously(with: dragGesture)
        .simultaneously(with: tapGesture)
    )
}

tapGesture を先に定義すると tapGesture が優先され、その結果長押しの指を離したときに、TapGesture の onEnd も実行されてしまうのかもしれません。そこで LongGesture を先に定義して優先度を高くすることで指を離したときのイベントは TapGesture に渡らなくなるのかと思いました。

まとめ

一つの View のシングルタップ時と長押し時のアクションを分けたいという要件は、レアかもしれませんが、同じような要件を実装したい方の一助になれば幸いです。

GitHubで編集を提案

Discussion