blog.covelline.com Open in urlscan Pro
35.75.255.9  Public Scan

URL: https://blog.covelline.com/entry/2023/08/04/143546
Submission: On October 18 via manual from JP — Scanned from JP

Form analysis 1 forms found in the DOM

GET https://blog.covelline.com/search

<form class="search-form" role="search" action="https://blog.covelline.com/search" method="get">
  <input type="text" name="q" class="search-module-input" value="" placeholder="記事を検索" required="">
  <input type="submit" value="検索" class="search-module-button">
</form>

Text Content

covelline blog
読者になる


COVELLINE BLOG

2023-08-04


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 を実装しました。



This file contains bidirectional Unicode text that may be interpreted or
compiled differently than what appears below. To review, open the file in an
editor that reveals hidden Unicode characters. Learn more about bidirectional
Unicode characters
Show hidden characters

import SwiftUI extension View { /// By reducing the hit detection, make it so
that tap detection occurs within the range of the frame /// /// In SwiftUI, a
View within about 10pt radius from the tap position is detected as touch, /// so
we place a contentShape that is just as small. func exactHitArea() -> some View
{ modifier(ExactHitAreaModifier()) } } private struct ExactHitAreaModifier:
ViewModifier { private let hitAreaPadding: CGFloat = 10 @State private var size:
CGSize? func body(content: Content) -> some View { let scale = size.map {
CGPoint( x: ($0.width - hitAreaPadding * 2) / $0.width, y: ($0.height -
hitAreaPadding * 2) / $0.height ) } ?? .zero content
.contentShape(Rectangle().scale(x: scale.x, y: scale.y)) .overlay {
GeometryReader { proxy in Color.clear .onAppear { self.size = proxy.size }
.onChange(of: proxy.size) { newValue in self.size = newValue } } } } }

view raw View+ExactHitArea.swift hosted with ❤ by GitHub
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 を作りました。



This file contains bidirectional Unicode text that may be interpreted or
compiled differently than what appears below. To review, open the file in an
editor that reveals hidden Unicode characters. Learn more about bidirectional
Unicode characters
Show hidden characters

import SwiftUI extension View { /** * This is a custom extension to SwiftUI's
onTapGesture, designed to avoid the issue where the tappable area extends
slightly beyond the view's drawing bounds. * Unlike the original onTapGesture,
this version ensures the tap is only recognized within the exact drawing bounds
of the View. */ func onTapGestureExact(_ action: @escaping (CGPoint) -> Void) ->
some View { overlay { GeometryReader { proxy in Color.clear
.contentShape(Rectangle()) .onTapGesture { location in if location.x >= 0,
location.y >= 0, location.x < proxy.size.width, location.y < proxy.size.height {
action(location) } } } } } }

view raw View+TapExact.swift hosted with ❤ by GitHub
gist.github.com



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


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

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



This file contains bidirectional Unicode text that may be interpreted or
compiled differently than what appears below. To review, open the file in an
editor that reveals hidden Unicode characters. Learn more about bidirectional
Unicode characters
Show hidden characters

import SwiftUI import UIKit /// UIKit でジェスチャを取得するための透明な View /// /// overlay
で利用する /// 参考 https://stackoverflow.com/a/57943387/1567777 struct
ClearLongPressGestureView: UIViewRepresentable { let onChanged: (Bool) -> Void
final class Coordinator: NSObject, UIGestureRecognizerDelegate { let onChanged:
(Bool) -> Void init(onChanged: @escaping (Bool) -> Void) { self.onChanged =
onChanged } public func gestureRecognizer(_: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer) -> Bool { true } @objc
func didLongPress(_ gesture: UILongPressGestureRecognizer) {
print("didLongPress\(gesture.state.rawValue)") switch gesture.state { case
.possible: break case .began: onChanged(true) case .ended, .cancelled, .failed:
onChanged(false) default: break } } } func makeCoordinator() -> Coordinator {
Coordinator(onChanged: onChanged) } func makeUIView(context:
UIViewRepresentableContext<ClearLongPressGestureView>) -> UIView { let view =
UIView() view.backgroundColor = .clear let longPress =
UILongPressGestureRecognizer(target: context.coordinator, action:
#selector(Coordinator.didLongPress)) longPress.minimumPressDuration = 0
longPress.delaysTouchesBegan = false longPress.delegate = context.coordinator
view.addGestureRecognizer(longPress) return view } func updateUIView(_: UIView,
context _: UIViewRepresentableContext<ClearLongPressGestureView>) {} }

view raw ClearLongPressGestureView.swift hosted with ❤ by GitHub
This file contains bidirectional Unicode text that may be interpreted or
compiled differently than what appears below. To review, open the file in an
editor that reveals hidden Unicode characters. Learn more about bidirectional
Unicode characters
Show hidden characters

import SwiftUI /// 厳密に frame を当たり判定とした ButtonStyle /// /// タップ時に少し透明にする struct
ExactTapButtonStyle: PrimitiveButtonStyle { enum Position { case overlay case
background } /// タップ判定の位置 /// ボタンを入れ子にするときには親に .background, 子に .overlay を指定すること
let position: Position @State private var isPressing: Bool = false func
makeBody(configuration: Configuration) -> some View { configuration.label
.opacity(isPressing ? 0.7 : 1) .background { if position == .background {
TransparentTapArea(isPressing: $isPressing) { _ in configuration.trigger() } } }
.overlay { if position == .overlay { TransparentTapArea(isPressing: $isPressing)
{ _ in configuration.trigger() } } } } } /// タップ判定を行うための透明な View private struct
TransparentTapArea: View { @Binding var isPressing: Bool let action: (CGPoint)
-> Void var body: some View { GeometryReader { proxy in Color.clear
.contentShape(Rectangle()) .overlay { // onLongPressGesture を使うと ScrollView
のスクロールを阻害するため // UIKit でタップ判定を行う ClearLongPressGestureView { value in isPressing
= value } } .onTapGesture { location in if proxy.size.contains(location) {
action(location) } } } } } private extension CGSize { func contains(_ point:
CGPoint) -> Bool { point.x >= 0 && point.y >= 0 && point.x < width && point.y <
height } }

view raw ExactTapButtonStyle.swift hosted with ❤ by GitHub
gist.github.com



#SwiftUI #UIKit

ryoheyc 1年前 読者になる



関連記事
 * 2024-09-21
   Flutter でバックグラウンドでの位置情報の取得の許可を取得する
   Flutter では geolocator を使ってバックグラウンドでの位置情…
 * 2024-09-18
   iOS 18でTextEditorに帯状の色が表示される問題について
   iOS18でfeather の投稿画面を表示すると、TextEditorの部分に帯…
 * 2024-04-16
   SwiftUIのTextFieldでOptionalなStringをbindする
   SwiftUIのTextFieldにイニシャライザはinit(_ titleKey: Locali…
 * 2023-05-22
   SwiftUI でスクロールの上部にくっついて伸縮する View を作る
   こんにちは。亀山です。Twitter のプロフィール画面上部のバナ…
 * 2023-05-01
   ScrollView が DragGesture を中断して onEnded が呼ばれない問題
   どうもこんにちは。GW の混雑を回避するため休みをずらした亀山…

« node.js環境のFirebase Functions (多分Cl… JBRC協力店・協力自治体窓口マップを作り… »

プロフィール
covelline はてなブログPro
読者です 読者をやめる 読者になる 読者になる
26
このブログについて
検索

リンク
 * はてなブログ
 * ブログをはじめる
 * 週刊はてなブログ
 * はてなブログPro

最新記事
 * 【Android】依存しているライブラリの一覧画面を作りたい
 * コベリンしごおわ温泉部
 * 【Android】マナーモード、イヤホン接続時でも音を鳴らしたい
 * オフィス出社推奨日
 * このブログの取り組み

月別アーカイブ
 * ▼ ▶
   2024 (57)
   * 2024 / 9 (16)
   * 2024 / 8 (1)
   * 2024 / 7 (3)
   * 2024 / 6 (4)
   * 2024 / 5 (8)
   * 2024 / 4 (5)
   * 2024 / 3 (9)
   * 2024 / 2 (4)
   * 2024 / 1 (7)
 * ▼ ▶
   2023 (70)
   * 2023 / 12 (5)
   * 2023 / 11 (6)
   * 2023 / 10 (14)
   * 2023 / 9 (3)
   * 2023 / 8 (5)
   * 2023 / 7 (2)
   * 2023 / 6 (6)
   * 2023 / 5 (10)
   * 2023 / 4 (10)
   * 2023 / 3 (5)
   * 2023 / 2 (2)
   * 2023 / 1 (2)
 * ▼ ▶
   2022 (63)
   * 2022 / 12 (3)
   * 2022 / 11 (6)
   * 2022 / 10 (5)
   * 2022 / 9 (5)
   * 2022 / 8 (3)
   * 2022 / 7 (8)
   * 2022 / 6 (3)
   * 2022 / 5 (6)
   * 2022 / 4 (7)
   * 2022 / 3 (4)
   * 2022 / 2 (6)
   * 2022 / 1 (7)
 * ▼ ▶
   2021 (55)
   * 2021 / 12 (4)
   * 2021 / 11 (6)
   * 2021 / 10 (4)
   * 2021 / 9 (7)
   * 2021 / 8 (6)
   * 2021 / 7 (10)
   * 2021 / 6 (3)
   * 2021 / 5 (3)
   * 2021 / 4 (7)
   * 2021 / 3 (2)
   * 2021 / 2 (2)
   * 2021 / 1 (1)
 * ▼ ▶
   2020 (21)
   * 2020 / 12 (4)
   * 2020 / 11 (5)
   * 2020 / 10 (3)
   * 2020 / 9 (1)
   * 2020 / 8 (5)
   * 2020 / 7 (3)
 * ▼ ▶
   2019 (3)
   * 2019 / 11 (1)
   * 2019 / 6 (1)
   * 2019 / 3 (1)
 * ▼ ▶
   2018 (9)
   * 2018 / 11 (2)
   * 2018 / 10 (1)
   * 2018 / 9 (1)
   * 2018 / 8 (1)
   * 2018 / 7 (3)
   * 2018 / 5 (1)
 * ▼ ▶
   2017 (4)
   * 2017 / 10 (1)
   * 2017 / 9 (3)
 * ▼ ▶
   2016 (11)
   * 2016 / 11 (2)
   * 2016 / 9 (2)
   * 2016 / 8 (1)
   * 2016 / 7 (2)
   * 2016 / 2 (3)
   * 2016 / 1 (1)
 * ▼ ▶
   2015 (26)
   * 2015 / 12 (2)
   * 2015 / 11 (3)
   * 2015 / 10 (3)
   * 2015 / 9 (4)
   * 2015 / 8 (4)
   * 2015 / 7 (4)
   * 2015 / 6 (5)
   * 2015 / 2 (1)
 * ▼ ▶
   2014 (17)
   * 2014 / 10 (5)
   * 2014 / 8 (1)
   * 2014 / 7 (2)
   * 2014 / 6 (4)
   * 2014 / 5 (1)
   * 2014 / 4 (2)
   * 2014 / 2 (1)
   * 2014 / 1 (1)
 * ▼ ▶
   2013 (5)
   * 2013 / 12 (4)
   * 2013 / 11 (1)


はてなブログをはじめよう!

covellineさんは、はてなブログを使っています。あなたもはてなブログをはじめてみませんか?

はてなブログをはじめる(無料)
はてなブログとは
covelline blog

Powered by Hatena Blog | ブログを報告する




引用をストックしました

ストック一覧を見る 閉じる

引用するにはまずログインしてください

ログイン 閉じる

引用をストックできませんでした。再度お試しください

閉じる

限定公開記事のため引用できません。

読者です 読者をやめる 読者になる 読者になる
26