Open5

1つの画面内で複数のTextFieldがあり、TextFieldごとにカスタムバーに表示させるボタンを分岐させたいスレ

じょにー / iOSエンジニアじょにー / iOSエンジニア

詳細

実現したいこと

  • パターン1の3つのいずれかのTextFieldにカーソルを当てている場合、キーボードのカスタムバーにはTextField間を移動できるボタンとキーボードを閉じるボタンを追加したい。(ただしパターン2のTextFieldには移動できないようにしたい)
  • パターン2のキーボードのカスタムバーには移動させるボタンは表示させず、閉じるボタンのみを表示させたい。

現状

  • パターン1の3つのいずれかのTextFieldにカーソルを当てている場合でもパターン2のTextField2用の閉じるボタンが表示されてしまう
  • 同じくパターン2のTextFieldにカーソルを当てている場合でもパターン1のTextField用のボタンが表示されてしまう

対象画面

パターン1にフォーカス パターン2にフォーカス

ソースコード:

HogeScreen.swift
import SwiftUI

struct HogeScreen: View {
    @State private var viewModel: HogeViewModel = .init()
    @FocusState private var focusedField: Field?
    @FocusState private var isPattern2TextFieldFocused: Bool
    
    private enum Field {
        case field1, field2, field3
    }
    
    var body: some View {
        VStack(spacing: 32) {
            VStack(alignment: .leading, spacing: 4) {
                Text("パターン1")
                    .fontWeight(.black)
                textFields
            }
            VStack(alignment: .leading, spacing: 4) {
                Text("パターン2")
                    .fontWeight(.black)
                TextField("", text: $viewModel.value1)
                    .textFieldStyle(.roundedBorder)
                    .focused($isPattern2TextFieldFocused)
                    .toolbar {
                        pattern2TextFieldKeyboardCustomBar
                    }
            }
            Spacer()
        }
        .padding(16)
    }
    
    var textFields: some View {
        HStack(spacing: 4) {
            TextField("値1", text: $viewModel.value1)
                .focused($focusedField, equals: .field1)
            TextField("値2", text: $viewModel.value2)
                .focused($focusedField, equals: .field2)
            TextField("値3", text: $viewModel.value3)
                .focused($focusedField, equals: .field3)
        }
        .textFieldStyle(.roundedBorder)
        .toolbar {
            pattern1TextFieldsKeyboardCustomBar
        }
    }
    
    var pattern1TextFieldsKeyboardCustomBar: some ToolbarContent {
        ToolbarItemGroup(placement: .keyboard) {
            HStack(spacing: 4) {
                Button(action: {
                    moveToPreviousField()
                }) {
                    Image(systemName: "chevron.left")
                }
                Button(action: {
                    moveToNextField()
                }) {
                    Image(systemName: "chevron.right")
                }
                Spacer()
                Button(action: {
                    focusedField = nil // キーボードを閉じる
                }) {
                    Text("閉じる")
                }
            }
        }
    }
    
    var pattern2TextFieldKeyboardCustomBar: some ToolbarContent {
        ToolbarItemGroup(placement: .keyboard) {
            Button(action: {
                isPattern2TextFieldFocused = false
            }) {
                Text("閉じる")
            }
        }
    }
}

private extension HogeScreen {
    func moveToPreviousField() {
        switch focusedField {
        case .field1:
            focusedField = .field3
        case .field2:
            focusedField = .field1
        case .field3:
            focusedField = .field2
        default:
            focusedField = nil
        }
    }

    func moveToNextField() {
        switch focusedField {
        case .field1:
            focusedField = .field2
        case .field2:
            focusedField = .field3
        case .field3:
            focusedField = .field1
        default:
            focusedField = nil
        }
    }
}

#Preview {
    HogeScreen()
}
HogeViewModel.swift
@Observable
final class HogeViewModel {
    var value1: String = ""
    var value2: String = ""
    var value3: String = ""
}

否めないク⭕️コード感...パターン1のTextFieldをenumで管理してるのもこれでいいのかと思うし、 @FocusStateが2つあるのも...🤔
そしてprivate extension HogeScreenはViewModelに移行させたすぎる

じょにー / iOSエンジニアじょにー / iOSエンジニア

改善の余地はありそうだが、一旦望んでいる挙動は実現できた。

挙動:

パターン1にフォーカス パターン2にフォーカス

ソースコード:

HogeScreen.swift
import SwiftUI

struct HogeScreen: View {
    @State private var viewModel: HogeViewModel = .init()
    @FocusState private var focusedField: Field?
    
    private enum Field {
        case field1, field2, field3, memoField
    }
    
    var body: some View {
        VStack(spacing: 32) {
            pattern1View
            pattern2View
            Spacer()
        }
        .padding(16)
        .toolbar {
            ToolbarItemGroup(placement: .keyboard) {
                if focusedField == .memoField {
                    pattern2TextFieldKeyboardCustomBar
                } else {
                    pattern1TextFieldsKeyboardCustomBar
                }
            }
        }
    }
    
    var pattern1View: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text("パターン1")
                .fontWeight(.black)
            textFields
        }
    }
    
    var textFields: some View {
        HStack(spacing: 4) {
            TextField("値1", text: $viewModel.value1)
                .focused($focusedField, equals: .field1)
            TextField("値2", text: $viewModel.value2)
                .focused($focusedField, equals: .field2)
            TextField("値3", text: $viewModel.value3)
                .focused($focusedField, equals: .field3)
        }
        .textFieldStyle(.roundedBorder)
    }
    
    var pattern2View: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text("パターン2")
                .fontWeight(.black)
            TextField("", text: $viewModel.value1)
                .textFieldStyle(.roundedBorder)
                .focused($focusedField, equals: .memoField)
        }
    }
    
    var pattern1TextFieldsKeyboardCustomBar: some View {
        HStack(spacing: 4) {
            Button(action: {
                moveToPreviousField()
            }) {
                Image(systemName: "chevron.left")
            }
            Button(action: {
                moveToNextField()
            }) {
                Image(systemName: "chevron.right")
            }
            Spacer()
            Button(action: {
                focusedField = nil // キーボードを閉じる
            }) {
                Text("閉じる")
            }
        }
    }
    
    var pattern2TextFieldKeyboardCustomBar: some View {
        HStack(spacing: 4) {
            Spacer()
            Button(action: {
                focusedField = nil // キーボードを閉じる
            }) {
                Text("閉じる")
            }
        }
    }
}

private extension HogeScreen {
    func moveToPreviousField() {
        switch focusedField {
        case .field1:
            focusedField = .field3
        case .field2:
            focusedField = .field1
        case .field3:
            focusedField = .field2
        default:
            focusedField = nil
        }
    }

    func moveToNextField() {
        switch focusedField {
        case .field1:
            focusedField = .field2
        case .field2:
            focusedField = .field3
        case .field3:
            focusedField = .field1
        default:
            focusedField = nil
        }
    }
}

#Preview {
    HogeScreen()
}

にしてもprivate extensionで定義しているこいつはViewModelに移行したいものだ。なんかできそう。
今日時間ないので来週いっぱいで考えて実装してみる。

yagijinyagijin

同じように困っていて、こんな感じでtoolbar modifier内で表示を出し分けるようにしたコンポーネント経由で使うようにしたら同一画面の複数のTextFieldやTextEditorでもうまいこと制御できました!(これも微妙な実装ではあるのですが...)
引数にToolbarItemに出したいViewを取るようにすれば、上の実装も簡素化できるかもしれません。

struct TextEditorWithToolbar: View {
    @Binding var text: String
    @FocusState private var focus: Bool

    var body: some View {
        TextEditor(text: $text)
            .focused($focus)
            .toolbar {
                if focus {
                    ToolbarItem(placement: .keyboard) {
                        HStack {
                            Spacer()
                            Button("閉じる") {
                                self.focus = false
                            }
                        }
                    }
                }
            }
       }
}