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
Submission: On October 18 via manual from JP — Scanned from JP
Form analysis
1 forms found in the DOMGET 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