[SwiftUI] SwiftUIでタップと長押しを区別する方法
概要
SwiftUI には、タップイベントを取得するonTapGestureやTapGesture、長押しイベントを取得するonLongPressGestureやLongPressGestureが存在します。
しかし投稿時点では、私が調査した限り一つの View のタップと長押しを区別できるような API は存在していません。本稿では、試みたが NG となった案と、最終的に解決することができた方法をお伝えします。
環境
- iOS17, iOS18
- Xcode16.1
NG 案 ①
ButtonStyleConfiguration のisPressedを使用することで、「ボタンが押されたこと」と「離されたこと」を検知することができます。
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 案 ②
onTapGesture
とonLongPressGesture
のonPressingChanged
を組み合わせることで、タップと長押しを判定することはできそうです。
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
ではisPressed
がtrue
のときは無視しています。これはタップ時でもtrue
が流れてきてしまうためです。そのため、perform
コールバックが実行されるまで少しラグがあり、その結果isLongPressed
がtrue
になるまで時間がかかってしまいます。
iOS17 だと挙動が異なる
私の環境の iOS18.0 と iOS17.5 のシミュレータでonLongPressGesture
の挙動を確認したところ、onPressingChanged
のisPressed
がfalse
になるタイミングが OS 間で異なっていました。
iOS18 では、指を離したタイミングでisPressed
がfalse
となりました。
しかし 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
のみでは可能。)そこでDragGesture
のonEnded
を使用することで、長押しアクションを終了するために指を離したというイベントを取得することができました。
simultaneously(with:)
を使用
タップまたは長押しを開始した直後では、それがタップイベントなのか長押しイベントなのかは判定できません。そのため、TapGesture
とLongPressGesture
は同時に認識できるようにしておく必要があります。ジェスチャを同時に認識するための方法として、simultaneousGesture(_:including:)とsimultaneously(with:)が挙げられます。
最初どっちでも一緒だと思ってsimultaneousGesture(_:including:)
を使っていたのですが、自作したonPressGesture
を以下のように複数箇所で利用すると、イベントが同時に発生してしまいました。
func body(content: Content) -> some View {
// simultaneousGestureを使っているので、ここ以外のsimultaneousGestureも同時に認識されてしまう
content
.gesture(longPressGesture)
.simultaneousGesture(dragGesture)
.simultaneousGesture(tapGesture)
}
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 のイベントが同時発火してしまいました。この事象がなぜ発生しているか解明できてはいませんが、おそらく優先度の問題なのかもしれません。
func body(content: Content) -> some View {
content
.gesture(
longPressGesture
.simultaneously(with: dragGesture)
.simultaneously(with: tapGesture)
)
}
tapGesture を先に定義すると tapGesture が優先され、その結果長押しの指を離したときに、TapGesture の onEnd も実行されてしまうのかもしれません。そこで LongGesture を先に定義して優先度を高くすることで指を離したときのイベントは TapGesture に渡らなくなるのかと思いました。
まとめ
一つの View のシングルタップ時と長押し時のアクションを分けたいという要件は、レアかもしれませんが、同じような要件を実装したい方の一助になれば幸いです。
Discussion