SwiftUI の View の当たり判定についての調査と対応

みなさんこんにちは。亀山です。非常に暑い日々が続いていますね。熱中症には気をつけてください。

ところで、feather for Mastodon を開発する中で、SwiftUI の困った点があります。それはタップ判定が View の frame よりも広くなっていることです。この仕様はボタン等をタップしやすくするという点ではよいのですが、feather はタップできる View が高い密度で配置されているため、誤タップを引き起こします。今回はこの挙動についての調査と、対応方法についての解説を行います。

結論

長いので先に結論を書きます。タップ範囲を修正する extension を作りました。ぜひ使ってください。

View+ExactHitArea.swift · GitHub

タップ範囲の調査

タップした位置に点を描画するコードを書いて試してみました。

struct ContentView: View {
    @State private var points = [CGPoint]()

    var body: some View {
        Rectangle()
            .fill(.red)
            .frame(width: 100, height: 100)
            .onTapGesture { location in
                print("onTapGesture: \(location)")
                points.append(location)
            }.overlay {
                ForEach(Array(points.enumerated()), id: \.offset) { _, location in
                    Circle()
                        .fill(.blue)
                        .frame(width: 4, height: 4)
                        .position(x: location.x, y: location.y)
                }
            }
    }
}

当たり判定が View の描画範囲よりも大きくとられています。基本的には 20pt ほど拡張されているようですが、勢いよく動かしながらタップしたりすると遠くでも判定したりしてかなり謎です。

contentShape をつけた場合

Rectangle

.contentShape(Rectangle()) をつけても同様の挙動です。

Circle

.contentShape(Circle()) をつけた場合にはなんとなく円形っぽい判定になっています。おそらく View を埋めるサイズの円に対して、20pt 拡張された判定になっていそうです。

Custom Shape

自前で三角形の Shape を実装して .contentShape に指定した場合にはやはり三角形になっています。

Offset

Rectangle()
    .fill(contentColor)
    .frame(width: 100, height: 100)
    .contentShape(.interaction, Rectangle().offset(x: 40, y: 40))

このようにして offset をつけると、当たり判定だけが移動しました。

View を並べた場合

隣り合ったときにはどう扱われるか試しました。VStack で並べたところこのようになりました。 恐ろしいことに、中間をタップしたときには、上の View (赤い点) のタップになることもあれば、下の View (青い点) のタップになることもあるようです。なんで?

.clipped() をつけても変わりません。

解決法

contentShape で小さい範囲を指定する

Rectangle()
    .fill(contentColor)
    .frame(width: 100, height: 100)
    .contentShape(.interaction, Rectangle().scale(x: 0.7, y: 0.7))

contentShape で .scale をつかって少し小さくした Shape を指定することで、拡張されるタップ範囲を打ち消すことができます。しかし、ここでは 0.7 としていますが適切な値は View のサイズによって異なるため、かなり使いづらいです。タップ範囲は View のサイズに関わらず 20pt ほど拡張されるので、View のサイズと掛け算して 20pt になる割合での指定をする必要があるためです。

この問題を回避するために、GeometryReader で View のサイズを取得して適切な値で縮めた contentShape を設定する extension を実装しました。

gist.github.com

下記のように利用します。

Button {
    print("tap image")
} label: {
    Rectangle()
        .fill(.blue)
        .frame(width: 200, height: 100)
}
.exactTapArea()

番外編: onTapGesture で範囲外のタップを取り除く

上記の contentShape での対応が一番筋が良さそうでしたが、他に試したこととして onTapGesture で範囲内か判定する方法についても記録しておきます。

Rectangle()
    .fill(contentColor)
    .frame(width: 100, height: 100)
    .contentShape(Rectangle())
    .onTapGesture { location in
        if location.x >= 0,
           location.y >= 0,
           location.x < 100,
           location.y < 100 {
            print("onTapGesture: \(location)")
        }
    }

上記のようにして location が矩形範囲内のときだけ処理を実行するようにします。 このままだと使い勝手が悪いので、GeometryReader で自身のサイズを取得します。GeometryReader はレイアウトに影響を与えて思わぬトラブルを起こすので、overlay 内でサイズを取得することにします。まとめて下記のような extension を作りました。

gist.github.com

この方法の欠点は、タップイベント自体は拡張された当たり判定で拾われてしまうことです。そのため Button や onTapGesture を入れ子にした場合に、境界部をタップすると該当の View も親の View もタップできない問題があります。

番外編: UIView でタップ判定を行う

より詳細にジェスチャを制御するには UIKit を利用してはどうかと思い、ジェスチャを追加した UIView を UIViewRepresentable で利用できるようにしてみました。 基本的には想定通り動くものの、この View を Button に入れ子にした場合、親の Button が反応してしまう問題があったため採用しませんでした。また、UIKit でジェスチャをつけた View に子として Text を入れた場合、Text 内のリンクが反応しなくなる問題もありました。

gist.github.com