見出し画像

SwiftUIでジェスチャーに対応

SwiftUIでは状態の扱いとともにジェスチャーなどイベントの扱いも『手続き型』の考え方とは違っています。
まったく違うのでこれまでプログラミング経験がある人ほど戸惑うのも当然です。
この記事で基本的なジェスチャーだけでなくGestureState属性とその更新について掘り下げて確認しましょう。

サンプル5-5実行例です。(オレンジ色の円をドラッグしています)

※この記事ではSwift言語の基本的な知識を前提にしています。
コード内のキーワードや書式などの不明点は Swift5初級ガイド などを参照してください。

※SwiftUIについて全体的なことは『SwiftUI最初の一歩』、コードの基本とビューについては『SwiftUIの文法 その1 View』を参照してください。

お知らせ
電子書籍『Swift5初級ガイド』をAppleのブックストアから出しています。
サンプルは無料です。
MacでもiPadでもiPhoneでも読めます。
Xcode 12に対応した第7版がダウンロード可能です。(ご購入済みの場合は無料アップデートです)
(2020年10月12日に第7版にアップデートしました)
Swift 5.3は第6版で対応済みです。
ブックストアから一度購入すると今後のアップデートは無料で読めます。

7宣伝store

・・・

・画像クリックで拡大表示できます
・画像を拡大表示中は画像の左右をクリックで画像だけを順に表示できます
・ソースコード部分は横にスクロール表示できます

サンプルはXcode 11.7のPlaygroundで作成しMacとiPadの Playgrounds 3.3.1 Xcode 12.0.1のPlaygroundで動作を確認しました。
この記事の最後(有料部分)にあるリンクから完全なサンプルをダウンロードできます。


1 イベントやジェスチャーについて

日常でも使う言葉になっている「イベント」は出来事や行事などの意味で使われています。
プログラミングの世界では、プログラム(アプリ)の外で発生した出来事で、プログラムの入力として扱うものを「イベント」と呼びます。

「ジェスチャー」は身振り手振りで何かを表現したり伝えたりすることです。
iPhoneのユーザガイドでは「ジェスチャ」と書かれています。
タッチ・スワイプ・ドラッグ・ピンチなどの操作のことです。
iOSではこれらの操作でパソコンとは全く違った『直接的』な操作を実現しています。

技術的には座標がひとつだけのマウス処理よりもかなり複雑です。
ジェスチャーを認識するだけでなく、ジェスチャー中も操作に対応するコードが直接操作している感覚のためには重要です。

1-1 イベント

iOSの世界では「タッチイベント」が最も重要です。

「タッチイベント」は(複数の)指で画面をタッチする操作ですが、専用のペンやApple Pencilでのタッチも含みます。

SwiftUIでは直接関係しませんが、利用者が慣れ親しんだ操作を実現する仕組みなので触りだけ説明します。

タッチイベントはUIKit(手続き型のフレームワーク)ではUITouchクラスのインスタンスを使ってあらわします。
UITouchの持っている情報の主なものは座標、発生したview、タップ数、タップ時刻、タッチかペンシルか、それにphase(フェーズ)と呼ばれる状態があります。

phaseには開始/移動/終了/キャンセル/静止などがあります。
タッチした瞬間に「開始」となり、タッチしたまま動かすと「移動」、はなすと「終了」です。
キャンセル」はタッチ中に電話の着信や通知画面が開いた場合などに発生します。

タッチイベントでスワイプやドラッグを処理するプログラミングはスキルが必要で、操作感を統一するためにもスワイプやドラッグを直接検出できる共通の機能が必要でした。
iOS 3.2以降でジェスチャーが使えるようになりました。

1-2 ジェスチャー

UIKitでジェスチャーはgesture recognizer(ジェスチャー レコグナイザー)で処理します。
実際にはUIGestureRecognizerを継承したクラスで対応します。

ジェスチャーの種類にはタップ、ピンチ、回転、スワイプ、パン、画面ふちからのパンなどがあります。
ジェスチャー レコグナイザーは「タッチ状態や位置などの時間とともに変化するイベント」をジェスチャーと認識するためのものです。

HIG(Human Interface Guidelines)の標準ジェスチャーの解説(英文)もぜひ目を通してください。
標準ジェスチャー操作の実例ビデオを見ることができます。
いくつかありますが短いのですべて見てください。


2 タップの検出

ここからはSwiftUIでジェスチャー処理を説明します。
まずは最もシンプルで基本的な「タップ」から。

ビューへのタップを検出するには onTapGesture を使います。

onTapGestureはViewプロトコルのモディファイアーのひとつです。

// onTapGesture(count:perform:)
func onTapGesture(count: Int = 1, 
   perform action: @escaping () -> Void) -> some View

引数は二つあります。

最初の引数 count は検出するタップ数を渡します。
デフォルトで1が設定済みなので、シングルタップの検出ならこの引数は省略できます。
ダブルタップを扱う場合には2にします。

二つ目の引数 perform にはタップを検出した際のアクションをクロージャーで渡します。
@escaping属性が付いています。
クロージャーが保存され、タップした時に実行されるためです。
Swift5.2以前ではプロパティに self. をつけなければなりません。

Xcode 12からselfは不要になりますが Swift Playgroundsアプリでエラーになるのでこのサンプルではすべてself.を付けています

2-1 シングルタップの検出

角丸四角形をタップした回数を表示するサンプルです。

// サンプル2-1
import SwiftUI
import PlaygroundSupport

struct CounterView: View {
  @State private var totalNumberOfTaps = 0

  var body: some View {
     VStack {
        Text("タップ数をカウント").padding()

        Text("\(totalNumberOfTaps)")
           .font(.largeTitle)

        RoundedRectangle(cornerRadius:20, style:.continuous)
           .fill(Color.blue)
           .frame(width: 140, height: 100, alignment: .center)
           .onTapGesture {
              self.totalNumberOfTaps += 1
           }
     }
  }
}

// playgroundで実行する場合に必要なコード
PlaygroundPage.current.setLiveView(CounterView().padding())

1行目と2行目のimport文と最後のPlaygroundPage.current.setLiveViewは他のサンプルでもプレイグラウンドで実行する場合に必要です。

PlaygroundPage.current.setLiveView(CounterView().padding())の最後の.padding()は Xcode 12 のPlaygroundで実行する場合に必要です。

画像4

青い角丸四角形を1回タップすると検出し表示回数が一つ増えます。
角丸四角形以外をタップしても反応しません。

RoundedRectangleの

 .onTapGesture {
     self.totalNumberOfTaps += 1
  }

でタップイベントを処理しています。
onTapGesture(count:perform:)の最初の引数を省略すると、デフォルトで1(シングルタップ)になります。
二つ目の引数はいつものようにトレイリングクロージャーで引数カッコの外にだしています。(performラベルも書きません)
クロージャーの処理はここではtotalNumberOfTapsプロパティーに1加えるだけです。
タップに対応する処理はこのクロージャーに自由に書くことができます。

2-2 ダブルタップの検出

ダブルタップを検出するにはonTapGesture(count:perform:)の最初の引数に2を渡します。

次のサンプルではシングルタップの回数とダブルタップの回数を別々にカウントします。

// サンプル2-2
struct CounterView: View {
  @State private var totalNumberOfTaps = 0
  @State private var totalNumberOfDoubleTaps = 0

  var body: some View {
     VStack {
        Text("タップとダブルタップをカウント").padding()

        Text("ダブルタップ数:\(totalNumberOfDoubleTaps)")
        Text("シングルタップ数:\(totalNumberOfTaps)")

        RoundedRectangle(cornerRadius:20, style:.continuous)
           .fill(Color.blue)
           .frame(width: 140, height: 100, alignment: .center)
           .onTapGesture(count: 2) {
              self.totalNumberOfDoubleTaps += 1
           }
           .onTapGesture(count: 1) {
              self.totalNumberOfTaps += 1
           }
     }
  }
}

最初のimport文と最後の行は割愛しています。サンプル2-1を参考にしてください。

画像5

.onTapGesture(count: 2)ではtotalNumberOfDoubleTapsをプラス1して、.onTapGesture(count: 1)ではtotalNumberOfTapsをプラス1しています。

.onTapGesture(count: 1) {はサンプル2-1のように .onTapGesture {と省略することも可能です。

2-3 タップとダブルタップの区別

ダブルタップは2度続けてタップです。
タップとタップの間隔があくとダブルタップではなく、二度のシングルタップと検出します(サンプル2-2を実行して確認してください)。

サンプル2-2でダブルタップの後はすぐにダブルタップ数が増えますが、シングルタップの時は若干時間がかかってシングルタップ数が増えることに気がつきましたか?
シングルタップは「ダブルタップ」ではないことが確認されてからシングルタップと判断しカウント処理が実行されます。

3回連続タップと4回連続タップなどもぜひためしてください。

サンプル2-2の.onTapGesture(count: 2)の順序を逆にしたのがサンプル2-3です。

このコードではダブルタップを検出できません
.onTapGesture(count: 1) { の処理がすべてのタップイベントを処理してしまい、.onTapGesture(count: 2) { にタップイベントが渡ってこないためと考えられます。

// サンプル2-3
struct CounterView: View {
  @State private var totalNumberOfTaps = 0
  @State private var totalNumberOfDoubleTaps = 0

  var body: some View {
     VStack {
        Text("タップとダブルタップをカウントその2").padding()

        Text("ダブルタップ数:\(totalNumberOfDoubleTaps)")
        Text("シングルタップ数:\(totalNumberOfTaps)")

        RoundedRectangle(cornerRadius:20, style:.continuous)
           .fill(Color.blue)
           .frame(width: 140, height: 100, alignment: .center)
           .onTapGesture(count: 1) {
              self.totalNumberOfTaps += 1
           }
           .onTapGesture(count: 2) {
              self.totalNumberOfDoubleTaps += 1
           }
        // シングルタップが先だとダブルタップを検出しない
     }
  }
}

実行画面は2-2と同じなので割愛します。

イベントの処理も順序が重要です。


3 長押しの検出

ビューへの長押しを検出するにはViewプロトコルの onLongPressGesture モディファイアーを使います。

3-1 onLongPressGestureその1

onLongPressGestureは引数違いの二つがあります。
まずonLongPressGesture(minimumDuration:pressing:perform:)から使ってみましょう。

// onLongPressGesture(minimumDuration:pressing:perform:)
func onLongPressGesture(minimumDuration: Double = 0.5, 

pressing: ((Bool) -> Void)? = nil, 

perform action: @escaping () -> Void) -> some View

引数は三つです。
最初の引数はminimumDurationで長押しと判断する時間を指定します。
この時間を長く設定すると「意識せず長押しと判断される」誤動作は少なくできますが、長すぎると操作者を待たせてしまいます。
デフォルトで0.5秒が設定されていて、省略した場合に0.5秒を設定したことになります。

二つ目の引数は長押し中に何かの処理を行う場合に使うクロージャーを渡します。
クロージャーは引数を一つ持ち値は返しません。
引数はタッチ状態です。
デフォルトではnilで何も設定しない状態です。
通常はデフォルトのままで使います。

三つ目はperformで長押しを検出した時に実行する処理をクロージャーで渡します。
@escaping属性が付いています。
クロージャーが保存され、長押しを検出した時に実行されるためです。
Swift5.2以前ではプロパティに self. をつけなければなりません。

次のサンプルコードは長押しでアラートを表示します。
タップ数のカウントに加え長押しでカウントをクリアするアラートを表示します。

// サンプル3-1
struct CounterView: View {
  @State private var totalNumberOfTaps = 0
  @State private var showingAlert = false

  var body: some View {
     VStack {
        Text("タップでカウント、長押しでクリア")

        Text("\(totalNumberOfTaps)")
           .font(.largeTitle)

        RoundedRectangle(cornerRadius:20, style:.continuous)
           .fill(Color.blue)
           .frame(width: 140, height: 100, alignment: .center)
           .onTapGesture {
              self.totalNumberOfTaps += 1
           }
           .onLongPressGesture {
              // ロングプレスでアラートを表示
              self.showingAlert = true
           }
           .alert(isPresented: $showingAlert) {
              Alert(title: Text("カウンタークリア"),
                   message: Text("クリアすると戻せません"),
                   primaryButton: .destructive(Text("クリアする")) {
                    self.totalNumberOfTaps = 0
                 },
                   secondaryButton: .cancel())
           }

     }
  }
}

iPadでの実行画面です。
長押しでカウンタークリアのアラートを表示します。

画像11

SwiftUIでのアラートの表示方法については「SwiftUIの画面切替」の「4 アラート表示」を参照してください。

Playgroundsアプリでは「キャンセル」とカナで表示しますが、Xcodeでは「Cancel」と表示するのはXcodeが日本語にローカライズされていないためです。

またMacのPlaygroundsアプリではアラートの形状が違います。

長押しを検出した場合の処理は最初と次の引数は省略しデフォルトのままで最後の引数はトレイリングクロージャーで書いています。
処理はshowingAlertプロパティをtrueにするだけです。

.onLongPressGesture {
  // ロングプレスでアラートを表示
  self.showingAlert = true
}

アラートの"クリアする"ボタンで totalNumberOfTaps プロパティをゼロにしています。

3-2 もう一つの長押し

長押し用のモディファイアーはメソッド名は同じで引数が違うonLongPressGesture(minimumDuration:maximumDistance:pressing:perform:)があります。

// onLongPressGesture(minimumDuration:maximumDistance:pressing:perform:)
func onLongPressGesture(minimumDuration: Double = 0.5, 
maximumDistance: CGFloat = 10, 
pressing: ((Bool) -> Void)? = nil, 
perform action: @escaping () -> Void) -> some View

引数にmaximumDistanceが増えていますが、そのほかはonLongPressGesture(minimumDuration:pressing:perform:)と同じです。

引数maximumDistanceには許容する移動距離を実数で渡します。

タッチ中にタッチ位置を移動するのは通常はドラッグとなります。
タッチ中はかなり意識しないとタッチ位置が微妙に動いてしまいます。
長押し判別の時間minimumDurationを長くした場合に特に影響が出てしまいます。
アクセシビリティーの向上などのために許容量を増やしたい場合にこの引数を使います。
デフォルトで10.0ポイントが設定済みです。

次のサンプルでは角丸長方形の幅をbuttonWidth定数に変更しmaximumDistanceにも同じ値を渡しています。(maximumDistanceが極端に大きい例です)

// サンプル3-2
struct CounterView: View {
  @State private var totalNumberOfTaps = 0
  @State private var showingAlert = false
  let buttonWidth: CGFloat = 140

  var body: some View {
     VStack {
        Text("タップでカウント、長押しでクリア")

        Text("\(totalNumberOfTaps)")
           .font(.largeTitle)

        RoundedRectangle(cornerRadius:20, style:.continuous)
           .fill(Color.blue)
           .frame(width: buttonWidth, height: 100, alignment: .center)
           .onTapGesture {
              self.totalNumberOfTaps += 1
           }
           .onLongPressGesture(maximumDistance: buttonWidth) {
              // ロングプレスでアラートを表示
              self.showingAlert = true
           }
           .alert(isPresented: $showingAlert) {
              Alert(title: Text("カウンタークリア"),
                   message: Text("クリアすると戻せません"),
                   primaryButton: .destructive(Text("クリアする")) {
                       self.totalNumberOfTaps = 0
                    },
                   secondaryButton: .cancel())
           }

     }
  }
}

実行画面は割愛します。

ここまでの三つはViewプロトコルのモディファイアー
onTapGesture(count:perform:)
onLongPressGesture(minimumDuration:pressing:perform:)
onLongPressGesture(minimumDuration:maximumDistance:pressing:perform:)
を使ったタップ処理です。
これらは手軽に使えるようViewプロトコルに含まれています


4 Gestureプロトコル

SwiftUIのジェスチャーはGestureプロトコルを使って処理します。
ジェスチャー処理はGestureプロトコルに準拠した型のインスタンスをgesture(_:including:)モディファイアーでビューに追加します。

Gestureプロトコルを使うジェスチャー対応は複数の型が関連します(4-4を参照してください)。
まずはタップに対応するサンプル4-2を使って説明します。

4-1 gesture(_:including:)モディファイアー

gesture(_:including:)もViewプロトコルのモディファイアーです。

// gesture(_:including:)モディファイアー
func gesture<T>(_ gesture: T, 
including mask: GestureMask = .all) -> some View where T : Gesture

引数は二つあります。

最初の引数はラベルなしでビューに追加するジェスチャーです。
Gestureプロトコルに準拠したインスタンスを渡します。

二つ目の引数includingはイベントのマスクを設定します。
ビューにジェスチャーを追加することで、ビューとそのサブビューで認識される他のジェスチャーにどのような影響を与えるかを制御するオプションです。
デフォルトで.allが設定済みなので省略できる引数です。

4-2 struct TapGestureでタップ検出

struct TapGestureはGestureプロトコルに準拠したタップを認識するための型です。

// TapGestureのイニシャライザ
init(count: Int = 1)

TapGesture型のイニシャライザはカウント数を引数で渡します。
カウント数のデフォルトは1です。

TapGesture型の使い方は次のサンプルです。
このサンプルはstruct TapGestureのドキュメントに載っているものです。(Swift Playgroundsアプリのドキュメントには載っていませんがXcode11.7のドキュメントには載っています)

// サンプル4-2
struct TapGestureView: View {
  @State var tapped = false

  var tap: some Gesture {
     TapGesture(count: 1)
        .onEnded { _ in self.tapped = !self.tapped }
  }

  var body: some View {
     Circle()
        .fill(self.tapped ? Color.blue : Color.red)
        .frame(width: 100, height: 100, alignment: .center)
        .gesture(tap)
  }
}

// playgroundで実行する場合に必要なコード
PlaygroundPage.current.setLiveView(TapGestureView().padding())

円の中をタップすると円の色が切りかわります。

画像11

ゆっくりタップすると、タッチ中ではなく指をはなすと色が変わることがわかります。
.onEndedメソッドで『タップ処理が完了』してから色を反転させるプロパティtappedを変更しているためです。

ここから先は

16,511字 / 8画像 / 1ファイル
この記事のみ ¥ 500

今後も記事を増やすつもりです。 サポートしていただけると大変はげみになります。