🚀

TapGestureの競合の解決方法の検討

に公開

発生したTapGestureの競合

個人開発アプリの下記の画像のプロフィール作成画面でTapGestureの競合が起きることで、PhotosPickerの「ライブラリから画像を選択ボタン」が押せる時と押せない時があるというバグが発生していました。

下記のようなコードを書いていました。
PhotosPickerの「ライブラリから画像を選択ボタン」が押せない時はonTapGesture内のfocusedField = nilが呼ばれてしまっていました。
PhotosPickerのTapGestureとonTapGestureのTapGestureの競合によるバグだと考え、解決方法の検討を行いました。

SignInProfileView.swift
struct SignInProfileView: View {
    @FocusState private var focusedField: Field?
    @ObservedObject var viewModel: AuthenticationViewModel
    @State var selectedImage: UIImage?
    @State var selectedItem: PhotosPickerItem?
    ...........

    //Modifierは省略...
    var body: some View {
        NavigationStack {
            VStack(spacing: 20) {
                VStack(spacing: 12) {
                    Text("プロフィール画像")
                    Image(uiImage: viewModel.selectedImage ?? UIImage(named: "noImage")!)
                    PhotosPicker(selection: $selectedItem) {
                        Text("ライブラリから画像を選択")
                    }
                }
                .onChange(of: selectedItem) {
                    Task {
                        guard let data = try? await selectedItem?.loadTransferable(type: Data.self) else { return }
                        guard let uiImage = UIImage(data: data) else { return }
                        viewModel.selectedImage = uiImage
                    }
                }
                VStack(alignment: .leading) {
                    Text("名前")
                    InputTextField(
                        inputTxet: $viewModel.displayName,
                        count: 10,
                        placeHolderText: "名前",
                        focused: $focusedField,
                        equals: .name
                    )
                    .onChange(of: viewModel.displayName) {
                        viewModel.displayNameTotalCount = viewModel.displayName.count
                    }
                    Text("自己紹介")
                    ZStack(alignment: .topLeading) {
                        InputTextField(
                            inputTxet: $viewModel.bio,
                            count: 20,
                            placeHolderText: "自己紹介",
                            focused: $focusedField,
                            equals: .bio
                        )
                        .onChange(of: viewModel.bio) {
                            viewModel.bioTotalCount = viewModel.bio.count
                        }
                    }
                }
            }
        }
        .onTapGesture {
            focusedField = nil
        }
    }
}

private extension SignInProfileView {
    enum Field: Hashable {
        case name
        case bio
    }
}

下記のInputTextFieldというカスタムTextFieldを使用しています。

InputTextField.swift
struct InputTextField<FocusedValue: Hashable>: View {
    @Binding private var text: String
    private var maxCount: Int
    private var placeHolder: String
    private var focused: (FocusState<FocusedValue>.Binding, FocusedValue)

    var body: some View {
        VStack(spacing: 4) {
            TextField(
                "\(placeHolder)(\(maxCount)文字以内)",
                text: $text
            )
            .padding(12)
            .overlay {
                RoundedRectangle(cornerRadius: 10)
                    .stroke(Color.primary, lineWidth: 2)
            }
            HStack {
                Spacer()
                // 入力文字数の表示
                Text(" \(text.count) / \(maxCount)")
            }
        }
        // 10文字以上の時最後の文字を削除制限
        .onChange(of: text) {
            if $0.count > maxCount {
                text.removeLast($0.count - maxCount)
            }
        }
        .ifLet(focused) {
            $0.focused($1.0.projectedValue, equals: $1.1)
        }
    }
}

extension InputTextField {
    init(
        inputTxet: Binding<String>,
        count: Int,
        placeHolderText: String,
        focused: FocusState<FocusedValue>.Binding,
        equals focusedValue: FocusedValue
    ) {
        self.init(
            text: inputTxet,
            maxCount: count,
            placeHolder: placeHolderText,
            focused: (focused, focusedValue)
        )
    }
}

private extension View {
    @ViewBuilder
    func ifLet<Wrapped, Content: View>(
        _ wrapped: Wrapped?,
        @ViewBuilder transform: (Self, Wrapped) -> Content
    ) -> some View {
        if let wrapped {
            transform(self, wrapped)
        } else {
            self
        }
    }
}

解決方法の検討

解決方法検討① ZStackにonTapGestureをつける

まず、NavigationStackにonTapGestureをつけていましたが、ZStackにonTapGestureをつけるように変更し、挙動を確認してみました。
NavigationStack { ZStack { } .onTapGesture { focusedState = nil } }
結果: 「ライブラリから画像を選択ボタン」が押せる時と押せない時が発生

解決方法検討② TextFieldの入力フィールドにフォーカスがある時のみ「透明なタップ領域」を作成

下記のようにTextFieldの入力フィールドにフォーカスがある時のみ「透明なタップ領域」が画面に表示され、.onTapGesture { focusedState = nil }が実行される。

SignInProfileView.swift
+ if focusedField != nil {
+                Color.clear
+                   .contentShape(Rectangle())
+                   .onTapGesture {
+                        focusedField = nil
+                    }
+            }

- .onTapGesture {
-            focusedField = nil
-        }

結果: キーボードが閉じている状態の時は「ライブラリから画像を選択ボタン」が押せるようになった!
TextFieldの入力フィールドにフォーカスがあたっている時には「透明なタップ領域」が画面に表示されているので、「ライブラリから画像を選択ボタン」を押せず、.onTapGesture { focusedState = nil }が実行され、キーボードが閉じる。
解決方法検討②の懸念点としては、キーボードが閉じている状態の時のみしか「ライブラリから画像を選択ボタン」が押せないことです。

解決方法検討③

SimultaneousGestureを使用し、同時に発生し得る二つのジェスチャーの実装をしました。

SignInProfileView.swift
+ .simultaneousGesture(
+                TapGesture()
+                    .onEnded {
+                        focusedField = nil
+                    }
+            )

- .onTapGesture {
-            focusedField = nil
-        }

結果: どの状態であっても「ライブラリから画像を選択ボタン」が押せるようになった!
SimultaneousGestureを使用することで、PhotosPickerのTapGestureとonTapGestureのTapGestureの同時に発生し得る二つのジェスチャーに対応することが可能になった。

解決方法の採用

解決方法検討③を採用しました。その理由としては、TextFieldの入力フィールドにフォーカスがあたっている状態からでも「ライブラリから画像を選択ボタン」が押せることで、ユーザーが意図する挙動に近づけると考えたからです。

最後に

上記のSimultaneousGestureを使用する以外にも、TapGestureの競合の解決方法があれば教えてください!

Discussion