[SwiftUI] Longpress もできる Button の作り方

SwiftUI2021

長押しできる Button を作ります。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Monterery beta 5
  • Xcode 13 RC
  • iOS 15 beta

長押しできるButton

SwiftUI が用意している Button は、クリック できるボタンです。

ここでの "クリック” とは、Button 上にタッチして、(そこまでの経過時間にかかわらず、)Button 上で、タッチをやめることです。
# SwiftUI では、この "クリック” は通常 Tap と呼ばれます。

アプリケーションを開発していると、Button の長押しで別機能を実行できるようにしたい時があります。

Tap できる Button

Tap できる Button は、普通のボタンです。

使用したコードは以下です。ボタンをタップして 3秒後に、Text を戻すようにしています。


//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2021/09/15
//  © 2021  SmallDeskSoftware
//

import SwiftUI

struct ContentView: View {
    @State private var labelText: String = "nothing happens"
    var body: some View {
        VStack {
            Button(action: {
                labelText = "Pushed !"
                DispatchQueue.main.asyncAfter(deadline: .now() + 3) { labelText = "nothing happens" }
            }, label: {
                Text("Push me!")
            })
                .buttonStyle(.bordered)
                .padding()
            Text(labelText)
                .padding()
        }
    }
}

長押しを追加する

onLongPressGesture

長押しを検知するために、.onLongPressGesture という View Modifier が用意されているので付けてみました。


//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2021/09/15
//  © 2021  SmallDeskSoftware
//

import SwiftUI

struct ContentView: View {
    @State private var labelText: String = "nothing happens"
    var body: some View {
        VStack {
            Button(action: {
                labelText = "Pushed !"
                DispatchQueue.main.asyncAfter(deadline: .now() + 3) { labelText = "nothing happens" }
            }, label: {
                Text("Push me!")
            })
                .onLongPressGesture(perform: {
                    print("Long pressed")
                })
                .buttonStyle(.bordered)
                .padding()
            Text(labelText)
                .padding()
        }
    }
}

残念ながら、onLongPressGesture で指定している closure は、呼び出されませんでした。(タッチ終了時に action は、呼び出されます。)

推測ですが、原因は、Button で待っている TapGesture との共存ができていないことだと思います。

simultaneousGesture

複数の Gesture を設定するときに使用できる simultaneousGesture という View Modifier がありますので、使ってみます。


//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2021/09/15
//  © 2021  SmallDeskSoftware
//

import SwiftUI

struct ContentView: View {
    @State private var labelText: String = "nothing happens"
    var body: some View {
        VStack {
            Button(action: {
                labelText = "Pushed !"
                DispatchQueue.main.asyncAfter(deadline: .now() + 3) { labelText = "nothing happens" }
            }, label: {
                Text("Push me!")
            })
                .simultaneousGesture(
                    LongPressGesture().onEnded{ _ in
                        print("Long pressed")
                    }
                )
                .buttonStyle(.bordered)
                .padding()
            Text(labelText)
                .padding()
        }
    }
}

長押しすると、"Long pressed" と表示されます。短くタッチすると、"Pushed !" という表示に切り替わりますので、tapGesture と共存はできているようです。

ですが、まだ 問題点があります。

”長押し” "タッチ” いずれも、その終了時に、action が実行される。

なぜ、Tap と LongPress がまざるのか についての考察

UIKit や AppKit でコードを書いたことがあるとわかりやすいですが、実際に アプリが反応するユーザー操作は、「指が離されたこと」に対する反応です。
このことは、たいていのアプリで ボタンの上でタッチしたまま 別領域に動かして指を離すと ボタンを押したことにならないことで、確認できます。

このことが、Button の action に適用されていると想像します。つまり、Button の action は、「ボタン上から指が離されたことに対するアクション」であり
タップ(一瞬触って離すこと)を条件としていない と想像します。

ということであれば、Button と LongPressGesture を組み合わせて、タップとロングタップの両方を検知する Button を作ることはできそうです。

Tap と LongPressGesture を理解する Button

ここまでで分かったことを組み合わせて、LongPress も理解する Button を作りました。動作は以下の通りです。

以下は、使用したコードです。


//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2021/09/15
//  © 2021  SmallDeskSoftware
//

import SwiftUI

struct ContentView: View {
    @State private var labelText: String = "nothing happens"

    @State private var longPressed = false
    var body: some View {
        VStack {
            Button(action: {
                if longPressed {
                    longPressed = false
                } else {
                    labelText = "Pushed !"
                }
                DispatchQueue.main.asyncAfter(deadline: .now() + 5) { labelText = "nothing happens" }
            }, label: {
                Text("Push me!")
            })
                .simultaneousGesture(
                    LongPressGesture().onEnded{ _ in
                        longPressed = true
                        labelText = "Long pressed !"
                    }
                )
                .buttonStyle(.bordered)
                .padding()
            Text(labelText)
                .padding()
        }
    }
}
action の実行タイミング
長押しされた場合は、長押しが検知された瞬間に処理を実行するようにしています。
action では、長押しだったかの判定を基準に 処理を実行するか判断していますが、長押しの処理を action 内に移動させることで、指を離した瞬間にいずれかの処理を実行するようにもできます。

LongPressableButton

@State 変数を追加して制御するようにしています。コードが煩雑にならないように、別ビューとして定義し、再利用しやすくしてみました。


struct LongPressableButton<Label>: View  where Label: View{
    var tapAction: (() -> Void)?
    var longPressAction: (() -> Void )?
    var label: (() -> Label)
    @State private var longPressed = false

    init(tapAction: (() -> Void)? = nil, longPressAction: (() -> Void)? = nil, label: @escaping (() -> Label) ) {
        self.tapAction = tapAction
        self.longPressAction = longPressAction
        self.label = label
    }

    var body: some View {
        Button(action: {
            if longPressed {
                longPressAction?()
                longPressed = false
            } else {
                tapAction?()
            }
        }, label: {
            label()
        })
            .simultaneousGesture(
                LongPressGesture().onEnded{ _ in
                    longPressed = true
                }
            )
            .padding()
    }
}

長押しと判定する秒数等の詳細設定を外部から渡せるようにするとさらに 汎用的な部品になりそうです。

例題として使っていたコードで 上記の LongPressableButton を使うと以下のようになります。


//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2021/09/15
//  © 2021  SmallDeskSoftware
//

import SwiftUI

struct ContentView: View {
    @State private var labelText: String = "nothing happens"

    var body: some View {
        VStack {
            LongPressableButton(tapAction: {
                labelText = "Pushed !"
                DispatchQueue.main.asyncAfter(deadline: .now() + 5) { labelText = "nothing happens" }
            }, longPressAction: {
                labelText = "Long pressed !"
                DispatchQueue.main.asyncAfter(deadline: .now() + 5) { labelText = "nothing happens" }
            }, label: {
                Text("Push me!")
            })
                .buttonStyle(.bordered)
                .padding()
            Text(labelText)
                .padding()
        }
    }
}

tapAction, longPressAction で共有する処理用に、commonAction を設定できるようにしても良いかもしれません。

まとめ:Longpress もできる Button の作り方

Longpress もできる Button の作り方
  • simultaneousGesture を使用して、Tap と LongPress を共存させる
  • LongPressGesture は、長押しと判定された瞬間に実行されるので、実行タイミングに注意する
  • Button の action は、経緯に関係なく、指が離されたタイミングで実行されるので、注意する

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です