Open29

SwiftUI: ToolbarItem(placement: .keyboard)とNavigationStackの相性

kabeyakabeya

まず、当該テキストフィールドにフォーカスが当たっているかどうかで、キーボードの閉じる閉じないを制御する必要がある、という話をおさらいします。

struct TextFieldWithCloseKeyboardButton: View {
    var title: LocalizedStringKey
    @Binding var text: String
    var prompt: LocalizedStringKey
    var closeButtonTitle: LocalizedStringKey
    @FocusState var isFocused: Bool
    
    var body: some View {
        TextField(self.title, text: $text, prompt: Text(self.prompt))
            .focused($isFocused)
            .toolbar {
                if isFocused {
                    ToolbarItem(placement: .keyboard) {
                        HStack {
                            Spacer()
                            Button(self.closeButtonTitle) {
                                isFocused = false
                            }
                        }
                    }
                }
            }
    }
}

struct TextFieldWithCloseKeyboardButtonWithoutFocusControl: View {
    var title: LocalizedStringKey
    @Binding var text: String
    var prompt: LocalizedStringKey
    var closeButtonTitle: LocalizedStringKey
    @FocusState var isFocused: Bool
    
    var body: some View {
        TextField(self.title, text: $text, prompt: Text(self.prompt))
            .focused($isFocused)
            .toolbar {
                ToolbarItem(placement: .keyboard) {
                    HStack {
                        Spacer()
                        Button(self.closeButtonTitle) {
                            isFocused = false
                        }
                    }
                }
            }
    }
}

仮想キーボードに「閉じる」ボタンが付く機能を持つカスタムテキストフィールドを2種類定義します。
1つは、自分にフォーカスが当たっているときだけ「閉じる」が付くもの(TextFieldWithCloseKeyboardButton)。もう1つは、常に「閉じる」が付いてしまうもの(TextFieldWithCloseKeyboardButtonWithoutFocusControl)。

ビューは以下のようにしてみます。

struct ContentView: View {
    @State var text1: String = ""
    @State var text2: String = ""
    
    var body: some View {
        NavigationStack {
            TextFieldWithCloseKeyboardButtonWithoutFocusControl(title: "名前", text: $text1, prompt: "名前", closeButtonTitle: "閉じる(名前)")
                .textFieldStyle(.roundedBorder)
            TextFieldWithCloseKeyboardButtonWithoutFocusControl(title: "住所", text: $text2, prompt: "住所", closeButtonTitle: "閉じる(住所)")
                .textFieldStyle(.roundedBorder)
//            TextFieldWithCloseKeyboardButton(title: "名前", text: $text1, prompt: "名前", closeButtonTitle: "閉じる(名前)")
//                .textFieldStyle(.roundedBorder)
//            TextFieldWithCloseKeyboardButton(title: "住所", text: $text2, prompt: "住所", closeButtonTitle: "閉じる(住所)")
//                .textFieldStyle(.roundedBorder)
        }
        .padding()
        
    }
}

フォーカスが当たっているかどうかをコントロールしない場合、フィールドを2つ画面に置くと、閉じるボタンも2つ付いてしまいます。(フィールドを3つ置けば、閉じるボタンも3つになります)

その上でフォーカスが当たっているフィールド用の「閉じる」しか機能しません。

こういうことを避けるため、フォーカスが当たっているかどうかによって、閉じるを付ける付けないを制御する必要があります。

kabeyakabeya

ところが、NavigationStackで、遷移先のビューを用意して遷移したときに、フォーカスによる制御の有無で動作が違う、ということが判明しました。

次のページのビューは以下の感じとします。

struct NextPage: View {
    @State var pickerSelection: Int = 1
    
    var body: some View {
        VStack {
            HStack {
                Picker("何かのタブ", selection: $pickerSelection) {
                    Text("タブ1").tag(1)
                    Text("タブ2").tag(2)
                }
                .pickerStyle(.segmented)
            }
            .background(Color(white: 0.9))
            ScrollView {
                Text("何にもないけど、次のシートです!")
                Spacer()
            }
        }
    }
}

親のビューは以下のようになります。

struct ContentView: View {
    @State var text1: String = ""
    @State var text2: String = ""
    
    var body: some View {
        NavigationStack {
//            TextFieldWithCloseKeyboardButtonWithoutFocusControl(title: "名前", text: $text1, prompt: "名前", closeButtonTitle: "閉じる(名前)")
//                .textFieldStyle(.roundedBorder)
//            TextFieldWithCloseKeyboardButtonWithoutFocusControl(title: "住所", text: $text2, prompt: "住所", closeButtonTitle: "閉じる(住所)")
//                .textFieldStyle(.roundedBorder)
            TextFieldWithCloseKeyboardButton(title: "名前", text: $text1, prompt: "名前", closeButtonTitle: "閉じる(名前)")
                .textFieldStyle(.roundedBorder)
            TextFieldWithCloseKeyboardButton(title: "住所", text: $text2, prompt: "住所", closeButtonTitle: "閉じる(住所)")
                .textFieldStyle(.roundedBorder)
            NavigationLink(destination: {
                NextPage()
            }, label: {
                Text("次のページ")
            })
        }
        .padding()
        
    }
}

初期状態で「次のページ」をタップすると以下のように期待通りに遷移します。

ですが、テキストフィールドにフォーカスが当たっている状態(つまりキーボードが開いた状態)で「次のページ」をタップすると、以下のように遷移します。

つまり、画面上部に何か余分なスペースが入ってしまいます。

これが、どういうわけかテキストフィールドのフォーカスによらず閉じるを付けた場合は、キーボードが開いた状態でも同じように期待通りに(つまり余分なスペースが入らずに)動作します。

kabeyakabeya

詳細な原因は結局分かっていませんが、ひとまず遷移する前にフォーカスを外せば良いということが分かりました。
ただし、NavigationLinkではタップから遷移するまでの間で制御ができないため、.navigationDestinationモディファイアを使うように変更しました。

struct ContentView: View {
    enum FocusTarget {
        case name
        case address
    }
    @State var text1: String = ""
    @State var text2: String = ""
    @FocusState var focusedField: FocusTarget?
    @State var showNextPage: Bool = false
    
    var body: some View {
        NavigationStack {
//            TextFieldWithCloseKeyboardButtonWithoutFocusControl(title: "名前", text: $text1, prompt: "名前", closeButtonTitle: "閉じる(名前)")
//                .textFieldStyle(.roundedBorder)
//            TextFieldWithCloseKeyboardButtonWithoutFocusControl(title: "住所", text: $text2, prompt: "住所", closeButtonTitle: "閉じる(住所)")
//                .textFieldStyle(.roundedBorder)
            TextFieldWithCloseKeyboardButton(title: "名前", text: $text1, prompt: "名前", closeButtonTitle: "閉じる(名前)")
                .textFieldStyle(.roundedBorder)
                .focused($focusedField, equals: .name)
            TextFieldWithCloseKeyboardButton(title: "住所", text: $text2, prompt: "住所", closeButtonTitle: "閉じる(住所)")
                .textFieldStyle(.roundedBorder)
                .focused($focusedField, equals: .address)
            Button("次のページ") {
                focusedField = nil
                showNextPage = true
            }
            .navigationDestination(isPresented: $showNextPage) {
                NextPage()
            }
        }
        .padding()
        
    }
}

リンクではなくボタンにして、ボタンのアクションとして「フォーカスを外した上で、遷移を発火させる」というような変更になります。

kabeyakabeya

ここまで来ると、テキストフィールド側で制御するのはどうなのか、という気がしますね。

kabeyakabeya

これでもダメなケースがありました。

このビューがシートに載っている場合はうまく行きません。
シートからナビゲーションで隣に遷移するというのはどうか、という議論はあると思いますが。

struct FrameView: View {
    @State var showSheet: Bool = false
    
    var body: some View {
        Button("シート表示") {
            showSheet.toggle()
        }
        .sheet(isPresented: $showSheet) {
            ContentView()
        }
    }
}

ContentViewが以上のようなFrameViewからシートで表示されるとすると、やはり画面上部に変なスペースが生まれます。

ちなみにフォーカスの有無をチェックしないバージョンのテキストフィールドを使えば特に問題なく遷移します(問題はその場合、閉じるボタンが複数付いちゃう、ということです)。

kabeyakabeya

シートに載せる場合、ContentViewを通常のTextFieldを使って以下のようにしてもダメですね。

struct ContentView: View {
    enum FocusTarget {
        case name
        case address
    }
    @State var text1: String = ""
    @State var text2: String = ""
    @FocusState var focusedField: FocusTarget?
    @State var showNextPage: Bool = false
    
    var body: some View {
        NavigationStack {
            TextField("名前", text: $text1, prompt: Text("名前"))
                .textFieldStyle(.roundedBorder)
                .focused($focusedField, equals: .name)
            TextField("住所", text: $text2, prompt: Text("住所"))
                .textFieldStyle(.roundedBorder)
                .focused($focusedField, equals: .address)
            Button("次のページ") {
                focusedField = nil
                showNextPage = true
            }
            .navigationDestination(isPresented: $showNextPage) {
                NextPage()
            }
            .toolbar {
                if focusedField != nil {
                    ToolbarItem(placement: .keyboard) {
                        HStack {
                            Spacer()
                            Button("閉じる") {
                                focusedField = nil
                            }
                        }
                    }
                }
            }
        }
        .padding()
        
    }
}

ただしこの例の場合if focusedField != nil {←この行が要らないので、ここを削除すれば問題なく表示されます。

kabeyakabeya

色々テストをすると、@FocusStateの変数と連動して「閉じる」を表示しようとするとダメ、という感じのようです。
「閉じる」を表示するかどうか、というshowKeyboardCloseButton変数を用意し、

  1. @FocusStateの変数にそれを連動させるようにする
  2. ただしNavigationStackで遷移させるときはshowKeyboardCloseButton変数を直接falseにしてから遷移する

とやれば良さそうです。

struct ContentView: View {
    enum FocusTarget {
        case name
        case address
    }
    @State var text1: String = ""
    @State var text2: String = ""
    @FocusState var focusedField: FocusTarget?
    @State var showNextPage: Bool = false
    @State var showKeyboardCloseButton: Bool = false
    
    var body: some View {
        NavigationStack {
            TextField("名前", text: $text1, prompt: Text("名前"))
                .textFieldStyle(.roundedBorder)
                .focused($focusedField, equals: .name)
            TextField("住所", text: $text2, prompt: Text("住所"))
                .textFieldStyle(.roundedBorder)
                .focused($focusedField, equals: .address)
            Button("次のページ") {
                showKeyboardCloseButton = false
                showNextPage = true
            }
            .onChange(of: focusedField) { oldFocusTarget, newFocusTarget in
                showKeyboardCloseButton = (newFocusTarget != nil)
            }
            .navigationDestination(isPresented: $showNextPage) {
                NextPage()
            }
            .toolbar {
                if showKeyboardCloseButton {
                    ToolbarItem(placement: .keyboard) {
                        HStack {
                            Spacer()
                            Button("閉じる") {
                                focusedField = nil
                            }
                        }
                    }
                }
            }
        }
        .padding()
        
    }
}
kabeyakabeya

最初に戻って、もしテキストフィールド側に「キーボードを閉じる」ボタンを付けるとした場合でかつ、シート上に表示されるビューでもうまく動かそうとすると、

struct TextFieldWithCloseKeyboardButtonNew: View {
    var title: LocalizedStringKey
    @Binding var text: String
    var prompt: LocalizedStringKey
    var closeButtonTitle: LocalizedStringKey
    @FocusState var isFocused: Bool
    var showKeyboardCloseButton: Bool
    
    var body: some View {
        TextField(self.title, text: $text, prompt: Text(self.prompt))
            .focused($isFocused)
            .toolbar {
                if isFocused && showKeyboardCloseButton {
                    ToolbarItem(placement: .keyboard) {
                        HStack {
                            Spacer()
                            Button(self.closeButtonTitle) {
                                isFocused = false
                            }
                        }
                    }
                }
            }
    }
}

こんな感じでshowKeyboardCloseButtonのような外部から制御する変数を渡します。
基本的には自分自身のフォーカスの有無で制御しますが、showKeyboardCloseButtonfalseなら、フォーカスの有無によらず「閉じる」は表示しません。
呼び出し側では以下のようにしてやります。

struct ContentView: View {
    @State var text1: String = ""
    @State var text2: String = ""
    @State var showNextPage: Bool = false
    @State var showKeyboardCloseButton: Bool = true
    
    var body: some View {
        NavigationStack {
            TextFieldWithCloseKeyboardButtonNew(title: "名前", text: $text1, prompt: "名前", closeButtonTitle: "閉じる(名前)", showKeyboardCloseButton: showKeyboardCloseButton)
                .textFieldStyle(.roundedBorder)
            TextFieldWithCloseKeyboardButtonNew(title: "住所", text: $text2, prompt: "住所", closeButtonTitle: "閉じる(住所)", showKeyboardCloseButton: showKeyboardCloseButton)
                .textFieldStyle(.roundedBorder)
            Button("次のページ") {
                showKeyboardCloseButton = false
                showNextPage = true
            }
            .navigationDestination(isPresented: $showNextPage) {
                NextPage()
            }
            .onChange(of: showNextPage) { oldValue, newValue in
                if !newValue {
                    showKeyboardCloseButton = true
                }
            }
        }
        .padding()
        
    }
}
  • 通常時はshowKeyboardCloseButtontrueにしておく
  • NavigationStackでの遷移前にshowKeyboardCloseButtonfalseにする
  • NavigationStackから戻ってきたとき、showNextPagefalseになるので、そこを監視してshowKeyboardCloseButtontrueに戻す(戻さないと二度と「閉じる」が表示されなくなる)。

なんか嫌な雰囲気が漂いますね。

カスタムテキストフィールド側に@Binding変数を用意して、それが@FocusStateと連動するようにしようとしても、複数のテキストフィールドで@Bindingの変数を分けない限りテキストフィールド間が連動してしまい「閉じる」ボタンが複数表示されてしまいますので、こんなことが必要になります。

kabeyakabeya

ちなみにこの話は、iOS 17.1.2の実機で確認しています。
@FocusStateのバグのような気もしないでもないので、そのうちこんなことしなくて良くなるのかも知れません。

kabeyakabeya

色々試した結果、カスタムコントロール側に「キーボードを閉じる」機能を付けるのは得策ではない、と判断しました。
カスタムコントロール側ではどのケースで「キーボードを閉じるボタン」を表示すべきか、判断できなさそうです。

理屈では「自分にフォーカスが当たっていたら」キーボードを閉じるボタンを表示すれば良いのですが、現状「自分にフォーカスが当たっていたら」という状態を正しく取得できないケースがあります。

なんとなくですが、モディファイアという仕組みを使っている以上、仕方ないのかなという気がしています。

kabeyakabeya

色々試した結果、カスタムコントロール側に「キーボードを閉じる」機能を付けるのは得策ではない、と判断しました。

こうでもないのかな。
どうすっかな…。

複数のテキストフィールドがあるビューを部品化すると、画面側からキーボードをどうやって閉じるのか、という問題が出てきてしまいます。

kabeyakabeya

複数のテキストフィールドがあるビューを部品化すると、画面側からキーボードをどうやって閉じるのか

試してみました。

struct UserInfoInputView: View {
    @Binding var name: String
    @Binding var address: String
    
    var nameTitle: String = "名前"
    var addressTitle: String = "住所"
    
    var namePrompt: String = "姓と名の間は空白で区切ってください"
    var addressPrompt: String = "都道府県名から入力してください"
    
    var body: some View {
        VStack {
            HStack {
                Text(nameTitle)
                    .font(.caption)
                    .foregroundStyle(.gray)
                    .frame(width: 40)
                TextField(nameTitle, text: $name, prompt: Text(namePrompt))
                    .textFieldStyle(.roundedBorder)
            }
            HStack {
                Text(addressTitle)
                    .font(.caption)
                    .foregroundStyle(.gray)
                    .frame(width: 40)
                TextField(addressTitle, text: $address, prompt: Text(addressPrompt))
                    .textFieldStyle(.roundedBorder)
            }
        }
    }
}

struct ContentView: View {
    enum FocusTarget {
        case userInfo
        case memo
    }
    @State var name: String = ""
    @State var address: String = ""
    @State var memo: String = ""
    @State var showNextPage: Bool = false
    @FocusState var focusedField: FocusTarget?
    
    var body: some View {
        NavigationStack {
            UserInfoInputView(name: $name, address: $address)
                .focused($focusedField, equals: .userInfo)
            HStack {
                Text("メモ")
                    .font(.caption)
                    .foregroundStyle(.gray)
                TextField("メモ", text: $memo, prompt: Text("メモ"))
                    .textFieldStyle(.roundedBorder)
                    .focused($focusedField, equals: .memo)
            }
            Button("次のページ") {
                showNextPage = true
            }
            .navigationDestination(isPresented: $showNextPage) {
                NextPage()
            }
            .toolbar {
                if (focusedField != nil) {
                    ToolbarItem(placement: .keyboard) {
                        HStack {
                            Spacer()
                            Button("閉じる") {
                                focusedField = nil
                            }
                        }
                    }
                }
            }
        }
        .padding()
    }
}

struct FrameView: View {
    @State var showSheet: Bool = false
    
    var body: some View {
        Button("シート表示") {
            showSheet.toggle()
        }
        .sheet(isPresented: $showSheet) {
            ContentView()
        }
    }
}

やっていることは以下の通りです。

  • 名前と住所という2つのテキストフィールドを持つUserInfoInputViewという部品を作る
  • その部品の中ではキーボードを閉じるボタンは付けない
  • 画面の側にUserInfoInputViewの他、別のテキストフィールドを配置する
  • @FocusState変数は、UserInfoInputViewで1つ、という扱いにする。どのフィールドか?みたいなことはしない。
  • 画面側でキーボードを閉じるボタンを用意する

無事キーボードを閉じられるようです。
その上で、シート上からナビゲーション遷移をしても変なスペースが生まれませんし、「閉じる」ボタンが複数個生じるということもないようです。

ということで「画面側で閉じるボタンを用意する」ということでいいような気がします。

kabeyakabeya

ということで「画面側で閉じるボタンを用意する」ということでいいような気がします。

ただ?これちょっと思うのですが「画面側」というときの「画面」とは何か、という問題がありますね。
小さいコントロールも、スクリーンを丸々覆うビューも、どちらもViewで、明確な差異がないように思います。

TabViewで切り替えられる内側のビューは「画面」なのか。TabViewを乗っけている側で「閉じる」を付けるべきなのか、それとも切り替えられるビュー側で「閉じる」を付けるべきなのか。

テキストフィールドがあるのは内側のビューなので内側に付けるべきじゃないかとは思うのですが、これってコントロールに付けるのと何が違うの?どう判断するの?という話になるような気もします。

例えば、TabViewの切り替えられる側ビューに「閉じる」を付けておいたものの、TabView側に何かテキストフィールドが追加されたら、「閉じる」をTabView側に付け替えるの?ってなるでしょう。

遷移先のビューに影響を与える、という状況が引き起こしているので、それが直れば良いのですが…
(というか、キーボードに標準で「閉じる」を付けておいて欲しいんだけども)

kabeyakabeya

まだ苦労しています。

フォーカスが当たったときに、仮想キーボード?スクリーンキーボード?に「閉じる」ボタンが付くのですが、画面が表示されたあと最初にフォーカスが当たったときに表示されないことがあります。
表示されることもあります。
次に別のテキストフィールドにフォーカスを当てるともう表示されます。それ以降は最初のフィールドにフォーカスを当てても表示されます。

感触的には、.toolbarのない画面から.toolbarのある画面に来た直後が表示されないような気がしますが、そうじゃないのかも知れません。

kabeyakabeya

感触的には、.toolbarのない画面から.toolbarのある画面に来た直後が表示されないような気がしますが、そうじゃないのかも知れません。

色々試したところ、TabViewの2枚目以降のページでうまく表示されないようです。
いったん別のページに行って戻ってくると表示されるようになりますが、フォーカスの当たっているフィールドを移動するだけで表示されるようになることもあるようですので、正確にこうすると再現する or 回避できる、というものではない気がします。

なんとなく@FocusStateの変数の更新のタイミングと再描画がかかるタイミングの問題のように見えます。


追記)
2枚目以降、ではなくて初期表示のページ以外のページ、のようです。初期表示が2枚目なら、1枚目のページで表示されません。

具体的なコードは以下のようなものです。

struct InnerView: View {
    enum FocusTarget {
        case name
        case address
    }
    @FocusState var focusedField: FocusTarget?
    @State var text: String = ""
    
    var body: some View {
        VStack {
            TextField("名前", text: $text)
                .focused($focusedField, equals: .name)
            TextField("アドレス", text: $text)
                .focused($focusedField, equals: .address)
        }
        .toolbar {
            if focusedField != nil {
                ToolbarItem(placement: .keyboard) {
                    HStack {
                        Spacer()
                        Button("閉じる") {
                            focusedField = nil
                        }
                    }
                }
            }
        }
    }
}

struct ContentView: View {
    @State var selectedTab: Int = 2
    
    var body: some View {
        TabView(selection: $selectedTab) {
            InnerView()
                .tabItem { Text("タブ1") }.tag(1)
            InnerView()
                .tabItem { Text("タブ2") }.tag(2)
            InnerView()
                .tabItem { Text("タブ3") }.tag(3)
        }
    }
}

iOS 17.2.1の実機で確認しています。

kabeyakabeya

Stack Overflowに、100% Pure SwiftUIかつiOS 14でもやってみました、みたいな投稿がありました。

https://stackoverflow.com/questions/56941206/inputaccessoryview-view-pinned-to-keyboard-with-swiftui

で、これをベースにカスタムモディファイアを作ってみました。

struct KeyboardCloseButtonModifier<FocusTargetT> : ViewModifier where FocusTargetT : Hashable {
    var title: LocalizedStringKey
    var focusedField: FocusState<FocusTargetT?>.Binding
    
    func body(content: Content) -> some View {
        ZStack {
            content
            if focusedField.wrappedValue != nil {
                VStack(spacing: 0) {
                    Spacer()
                    Rectangle()
                        .frame(height: 0.5)
                        .foregroundStyle(.gray)
                    HStack {
                        Spacer()
                        Button(self.title) {
                            focusedField.wrappedValue = nil
                        }
                    }
                    .padding(.trailing, 16)
                    .frame(idealWidth: .infinity, maxWidth: .infinity, idealHeight: 44, maxHeight: 44, alignment: .center)
                    .background(Color(white: 0.95))
                }
            }
        }
    }
}

extension View {
    func keyboardCloseButton<FocusTargetT>(_ title: LocalizedStringKey, focused: FocusState<FocusTargetT?>.Binding) -> some View where FocusTargetT : Hashable {
        self.modifier(KeyboardCloseButtonModifier<FocusTargetT>(title: title, focusedField: focused))
    }
}

使い方は以下のようになります。あら簡単。

struct CustomToolbarInnerView: View {
    enum FocusTarget {
        case name
        case address
    }
    @FocusState var focusedField: FocusTarget?
    @State var text: String = ""
    
    var body: some View {
        VStack {
            TextField("名前", text: $text)
                .focused($focusedField, equals: .name)
            TextField("アドレス", text: $text)
                .focused($focusedField, equals: .address)
        }
        .keyboardCloseButton("閉じる", focused: $focusedField)
    }
}

呼び出す側も、先に書いた.toolbarで初期表示のページしかうまく動かない例と同等の書き方でOKです。

struct CustomToolbarTestView: View {
    @State var selectedTab: Int = 2
    
    var body: some View {
        TabView(selection: $selectedTab) {
            CustomToolbarInnerView()
                .tabItem { Text("タブ1") }.tag(1)
            CustomToolbarInnerView()
                .tabItem { Text("タブ2") }.tag(2)
            CustomToolbarInnerView()
                .tabItem { Text("タブ3") }.tag(3)
        }
    }
}
kabeyakabeya

Boolのバージョンもあるといいですね。
というわけで以下のようにしました。

struct KeyboardCloseButtonModifierGeneric<FocusTargetT> : ViewModifier where FocusTargetT : Hashable {
    var title: LocalizedStringKey
    var focusedField: FocusState<FocusTargetT?>.Binding
    
    func body(content: Content) -> some View {
        ZStack {
            content
            if focusedField.wrappedValue != nil {
                VStack(spacing: 0) {
                    Spacer()
                    Rectangle()
                        .frame(height: 0.5)
                        .foregroundStyle(.gray)
                    HStack {
                        Spacer()
                        Button(self.title) {
                            focusedField.wrappedValue = nil
                        }
                    }
                    .padding(.trailing, 16)
                    .frame(idealWidth: .infinity, maxWidth: .infinity, idealHeight: 44, maxHeight: 44, alignment: .center)
                    .background(Color(white: 0.95))
                }
            }
        }
    }
}

struct KeyboardCloseButtonModifierBool : ViewModifier {
    var title: LocalizedStringKey
    var focusedField: FocusState<Bool>.Binding
    
    func body(content: Content) -> some View {
        ZStack {
            content
            if focusedField.wrappedValue {
                VStack(spacing: 0) {
                    Spacer()
                    Rectangle()
                        .frame(height: 0.5)
                        .foregroundStyle(.gray)
                    HStack {
                        Spacer()
                        Button(self.title) {
                            focusedField.wrappedValue = false
                        }
                    }
                    .padding(.trailing, 16)
                    .frame(idealWidth: .infinity, maxWidth: .infinity, idealHeight: 44, maxHeight: 44, alignment: .center)
                    .background(Color(white: 0.95))
                }
            }
        }
    }
}

extension View {
    func keyboardCloseButton<FocusTargetT>(_ title: LocalizedStringKey, focused: FocusState<FocusTargetT?>.Binding) -> some View where FocusTargetT : Hashable {
        self.modifier(KeyboardCloseButtonModifierGeneric<FocusTargetT>(title: title, focusedField: focused))
    }
}

extension View {
    func keyboardCloseButton(_ title: LocalizedStringKey, focused: FocusState<Bool>.Binding) -> some View {
        self.modifier(KeyboardCloseButtonModifierBool(title: title, focusedField: focused))
    }
}
kabeyakabeya

ちなみに「このモディファイアを使うなら、もしかしてコントロール側につけてもOKかも?」と思ったのですが、そうは問屋が卸してくれませんでした。
このモディファイアを付けた部分の最下部にバーが表示されるため、コントロール側に付けるとコントロールの直下に入ってしまいます。

なので画面側につける、という制約は変わらず。

kabeyakabeya

このモディファイア、画面下部にテキストフィールドがある場合に、フィールドを隠してしまいますね。
ちょっと見直しが必要。

kabeyakabeya

見直して以下のようになりました。

protocol KeyboardCloseButtonModifierHelper {
    var isFocused: Bool { get }
    func unfocus()
}

struct KeyboardCloseButtonModifierHelerGeneric<FocusTargetT: Hashable> : KeyboardCloseButtonModifierHelper {
    var focusedField: FocusState<FocusTargetT?>.Binding
    
    var isFocused: Bool {
        return (focusedField.wrappedValue != nil)
    }
    
    func unfocus() {
        focusedField.wrappedValue = nil
    }
}

struct KeyboardCloseButtonModifierHelerBool : KeyboardCloseButtonModifierHelper {
    var focusedField: FocusState<Bool>.Binding
    
    var isFocused: Bool {
        return focusedField.wrappedValue
    }
    
    func unfocus() {
        focusedField.wrappedValue = false
    }
}

struct KeyboardCloseButtonModifier<FocusHelperT: KeyboardCloseButtonModifierHelper> : ViewModifier {
    var title: LocalizedStringKey
    var helper: FocusHelperT
    
    func body(content: Content) -> some View {
        let isFocused = helper.isFocused
        ZStack {
            VStack(spacing: 0) {
                content
                if isFocused {
                    Rectangle()
                        .frame(height: 44)
                }
            }
            if isFocused {
                VStack(spacing: 0) {
                    Spacer()
                    Rectangle()
                        .frame(height: 0.5)
                        .foregroundStyle(.gray)
                    HStack {
                        Spacer()
                        Button(self.title) {
                            helper.unfocus()
                        }
                    }
                    .padding(.trailing, 16)
                    .frame(idealWidth: .infinity, maxWidth: .infinity, idealHeight: 44, maxHeight: 44, alignment: .center)
                    .background(Color(white: 0.95))
                }
            }
        }
    }
}

extension View {
    func keyboardCloseButton<FocusTargetT>(_ title: LocalizedStringKey, focused: FocusState<FocusTargetT?>.Binding) -> some View where FocusTargetT : Hashable {
        self.modifier(KeyboardCloseButtonModifier(title: title, helper: KeyboardCloseButtonModifierHelerGeneric(focusedField: focused)))
    }
}

extension View {
    func keyboardCloseButton(_ title: LocalizedStringKey, focused: FocusState<Bool>.Binding) -> some View {
        self.modifier(KeyboardCloseButtonModifier(title: title, helper: KeyboardCloseButtonModifierHelerBool(focusedField: focused)))
    }
}

キーボードが表示されるとき、元のビューの最下部に、キーボードツールバーと同じ高さのRectangleをくっつけます。ZStackでの背面のビューになっていてユーザからは見えないので、幅とか色とかの見栄えは気にしません。

kabeyakabeya

このモディファイア、VStackZStackをビュー階層に追加してしまうので、Sectionなどに対して使うとListFormが崩れます。

ListFormに対して使えば良いのですが、そうできない場合があります。
(やり方の問題と言えばそうなのかも知れませんが)

例えば、Sectionのレベルで部品化していたとします。
その部品の中からNavigationLinkで遷移し、遷移先でテキストフィールドがあるとします。

このケースでは外側のListFormからだと、Section直下のフォーカスは拾えますが、遷移先のフォーカスは拾えません。

それならばSection.keyboardCloseButtonを追加すればいいんじゃ?ということで追加すると、冒頭の通り、間にVStackが入ってしまうのでListが崩れます。


色々考えましたが、

その部品の中からNavigationLinkで遷移し、遷移先でテキストフィールドがあるとします。

この、部品の中から遷移させるというのが良くないんじゃないかという気がします。
部品の中で遷移するボタンが押されたら、それを親ビューに通知してそこから遷移させるべき、という感じでしょうか。中から遷移させると、表がずれて見えるのもそのせいの気がしてきました。


部品化の外側で制御することになってしまいましたが、構成を考えるとこのほうが正しいように思えます。
動作も期待通りになりました。

kabeyakabeya

さらに問題が。
場合によっては、キーボードの上のツールバーにOK,キャンセルを表示して、キャンセルの場合はテキストをクリアした上で閉じる、というようにしたいことがあります。

なのでちょっと再修正しました。

protocol KeyboardCloseButtonModifierHelper {
    var isFocused: Bool { get }
    func unfocus()
}

struct KeyboardCloseButtonModifierHelerGeneric<FocusTargetT: Hashable> : KeyboardCloseButtonModifierHelper {
    var focusedField: FocusState<FocusTargetT?>.Binding
    var whenMatches: [FocusTargetT] = []
    
    var isFocused: Bool {
        if whenMatches.isEmpty {
            return (focusedField.wrappedValue != nil)
        }
        else {
            return whenMatches.contains(where: { $0 == focusedField.wrappedValue })
        }
    }
    
    func unfocus() {
        focusedField.wrappedValue = nil
    }
}

struct KeyboardCloseButtonModifierHelerBool : KeyboardCloseButtonModifierHelper {
    var focusedField: FocusState<Bool>.Binding
    
    var isFocused: Bool {
        return focusedField.wrappedValue
    }
    
    func unfocus() {
        focusedField.wrappedValue = false
    }
}

struct KeyboardCloseButtonModifier<FocusHelperT: KeyboardCloseButtonModifierHelper> : ViewModifier {
    var title: LocalizedStringKey
    var helper: FocusHelperT
    
    func body(content: Content) -> some View {
        let isFocused = helper.isFocused
        ZStack {
            VStack(spacing: 0) {
                content
                if isFocused {
                    Rectangle()
                        .frame(height: 44)
                }
            }
            if isFocused {
                VStack(spacing: 0) {
                    Spacer()
                    Rectangle()
                        .frame(height: 0.5)
                        .foregroundStyle(.gray)
                    HStack {
                        Spacer()
                        Button(self.title) {
                            helper.unfocus()
                        }
                    }
                    .padding(.trailing, 16)
                    .frame(idealWidth: .infinity, maxWidth: .infinity, idealHeight: 44, maxHeight: 44, alignment: .center)
                    .background(Color(white: 0.95))
                }
            }
        }
    }
}

struct KeyboardOKCancelButtonModifier<FocusHelperT: KeyboardCloseButtonModifierHelper> : ViewModifier {
    var okTitle: LocalizedStringKey
    var cancelTitle: LocalizedStringKey
    @Binding var text: String
    var helper: FocusHelperT
    
    func body(content: Content) -> some View {
        let isFocused = helper.isFocused
        ZStack {
            VStack(spacing: 0) {
                content
                if isFocused {
                    Rectangle()
                        .frame(height: 44)
                }
            }
            if isFocused {
                VStack(spacing: 0) {
                    Spacer()
                    Rectangle()
                        .frame(height: 0.5)
                        .foregroundStyle(.gray)
                    HStack {
                        Spacer()
                        Button(self.cancelTitle) {
                            text = ""
                            helper.unfocus()
                        }
                        Button(self.okTitle) {
                            helper.unfocus()
                        }
                    }
                    .padding(.trailing, 16)
                    .frame(idealWidth: .infinity, maxWidth: .infinity, idealHeight: 44, maxHeight: 44, alignment: .center)
                    .background(Color(white: 0.95))
                }
            }
        }
    }
}

extension View {
    func keyboardCloseButton<FocusTargetT>(_ title: LocalizedStringKey, focused: FocusState<FocusTargetT?>.Binding, whenMatches: [FocusTargetT] = []) -> some View where FocusTargetT : Hashable {
        self.modifier(KeyboardCloseButtonModifier(title: title, helper: KeyboardCloseButtonModifierHelerGeneric(focusedField: focused, whenMatches: whenMatches)))
    }
}

extension View {
    func keyboardCloseButton(_ title: LocalizedStringKey, focused: FocusState<Bool>.Binding) -> some View {
        self.modifier(KeyboardCloseButtonModifier(title: title, helper: KeyboardCloseButtonModifierHelerBool(focusedField: focused)))
    }
}

extension View {
    func keyboardOKCancelButton<FocusTargetT>(okTitle: LocalizedStringKey, cancelTitle: LocalizedStringKey, text: Binding<String>, focused: FocusState<FocusTargetT?>.Binding, whenMatches: [FocusTargetT] = []) -> some View where FocusTargetT : Hashable {
        self.modifier(KeyboardOKCancelButtonModifier(okTitle: okTitle, cancelTitle: cancelTitle, text: text, helper: KeyboardCloseButtonModifierHelerGeneric(focusedField: focused, whenMatches: whenMatches)))
    }
}

extension View {
    func keyboardOKCancelButton(okTitle: LocalizedStringKey, cancelTitle: LocalizedStringKey, text: Binding<String>, focused: FocusState<Bool>.Binding) -> some View {
        self.modifier(KeyboardOKCancelButtonModifier(okTitle: okTitle, cancelTitle: cancelTitle, text: text, helper: KeyboardCloseButtonModifierHelerBool(focusedField: focused)))
    }
}

whenMatches:という引数を追加しました。
同じ画面の中で、「閉じる」を表示したいフィールドと、「OK」「キャンセル」を表示したいフィールドがあるとして、.keyboardCloseButton.keyboardOKCancelButtonを併記し、引数で指定したEnum値にマッチした場合のみ、該当ボタンを表示する、という仕組みです。

以下のように使います。

struct OKCloseTestView: View {
    enum FocusTarget {
        case field1
        case field2
    }
    @State var field1Text: String = ""
    @State var field2Text: String = ""
    @FocusState var focusedField: FocusTarget?
    
    var body: some View {
    
        VStack {
            TextField("フィールド1", text: $field1Text)
                .focused($focusedField, equals: .field1)
            TextField("フィールド2", text: $field2Text)
                .focused($focusedField, equals: .field2)
            Spacer()
        }
        .padding()
        .keyboardCloseButton("閉じる", focused: $focusedField, whenMatches: [.field1])
        .keyboardOKCancelButton(okTitle: "OK", cancelTitle: "キャンセル", text: $field2Text, focused: $focusedField, whenMatches: [.field2])
    }
}

この例だと、フィールド1にフォーカスがある場合は「閉じる」が表示され、フィールド2にフォーカスがある場合は「OK」「キャンセル」が表示されます。

kabeyakabeya

キャンセルボタンのiOS17不具合回避版は以下になります。

struct KeyboardOKCancelButtonModifier<FocusHelperT: KeyboardCloseButtonModifierHelper> : ViewModifier {
    var okTitle: LocalizedStringKey
    var cancelTitle: LocalizedStringKey
    @Binding var text: String
    var helper: FocusHelperT
    
    func body(content: Content) -> some View {
        let isFocused = helper.isFocused
        ZStack {
            VStack(spacing: 0) {
                content
                if isFocused {
                    Rectangle()
                        .frame(height: 44)
                }
            }
            if isFocused {
                VStack(spacing: 0) {
                    Spacer()
                    Rectangle()
                        .frame(height: 0.5)
                        .foregroundStyle(.gray)
                    HStack {
                        Spacer()
                        Button(self.cancelTitle) {
                            if !self.text.isEmpty {
                                self.text = self.text + " "
                                Task { @MainActor in
                                    self.text = ""
                                }
                            }
                            helper.unfocus()
                        }
                        Button(self.okTitle) {
                            helper.unfocus()
                        }
                    }
                    .padding(.trailing, 16)
                    .frame(idealWidth: .infinity, maxWidth: .infinity, idealHeight: 44, maxHeight: 44, alignment: .center)
                    .background(Color(white: 0.95))
                }
            }
        }
    }
}
kabeyakabeya

iOS17不具合回避版に不備がありました。

unfocus()した後にself.text = ""が実行されるので、テキスト側で、OK=「フォーカスが外れていてかつテキストが空でない」(キャンセルはそれ以外)という判定をしていると、キャンセルを押してもOKになってしまっていました。
unfocus()Taskのクロージャに入れてやる必要があります。

修正版は以下になります。

struct KeyboardOKCancelButtonModifier<FocusHelperT: KeyboardCloseButtonModifierHelper> : ViewModifier {
    var okTitle: LocalizedStringKey
    var cancelTitle: LocalizedStringKey
    @Binding var text: String
    var helper: FocusHelperT
    
    func body(content: Content) -> some View {
        let isFocused = helper.isFocused
        ZStack {
            VStack(spacing: 0) {
                content
                if isFocused {
                    Rectangle()
                        .frame(height: 44)
                }
            }
            if isFocused {
                VStack(spacing: 0) {
                    Spacer()
                    Rectangle()
                        .frame(height: 0.5)
                        .foregroundStyle(.gray)
                    HStack {
                        Spacer()
                        Button(self.cancelTitle) {
                            if !self.text.isEmpty {
                                self.text = self.text + " "
                                Task { @MainActor in
                                    self.text = ""
                                    helper.unfocus()
                                }
                            }
                        }
                        Button(self.okTitle) {
                            helper.unfocus()
                        }
                    }
                    .padding(.trailing, 16)
                    .frame(idealWidth: .infinity, maxWidth: .infinity, idealHeight: 44, maxHeight: 44, alignment: .center)
                    .background(Color(white: 0.95))
                }
            }
        }
    }
}
kabeyakabeya

しかしこの「iOS17不具合回避版」の一文字分ピコッとスペースが入るの、よくよく見ると観察できます。
悲しいですね…

kabeyakabeya

まだダメでした(ちゃんとテストしてから投稿しろという話ですね)。
空のときにキャンセルを押したら閉じないようにデグってました。
以下、修正版です。

struct KeyboardOKCancelButtonModifier<FocusHelperT: KeyboardCloseButtonModifierHelper> : ViewModifier {
    var okTitle: LocalizedStringKey
    var cancelTitle: LocalizedStringKey
    @Binding var text: String
    var helper: FocusHelperT
    
    func body(content: Content) -> some View {
        let isFocused = helper.isFocused
        ZStack {
            VStack(spacing: 0) {
                content
                if isFocused {
                    Rectangle()
                        .frame(height: 44)
                }
            }
            if isFocused {
                VStack(spacing: 0) {
                    Spacer()
                    Rectangle()
                        .frame(height: 0.5)
                        .foregroundStyle(.gray)
                    HStack {
                        Spacer()
                        Button(self.cancelTitle) {
                            if !self.text.isEmpty {
                                self.text = self.text + " "
                                Task { @MainActor in
                                    self.text = ""
                                    helper.unfocus()
                                }
                            }
                            else {
                                helper.unfocus()
                            }
                        }
                        Button(self.okTitle) {
                            helper.unfocus()
                        }
                    }
                    .padding(.trailing, 16)
                    .frame(idealWidth: .infinity, maxWidth: .infinity, idealHeight: 44, maxHeight: 44, alignment: .center)
                    .background(Color(white: 0.95))
                }
            }
        }
    }
}