SwiftUI: ToolbarItem(placement: .keyboard)とNavigationStackの相性
以前に、仮想キーボードに閉じるボタンをつける際にちょっとハマった話を書きました。
もう1回ハマったので、再度書きます。
まず、当該テキストフィールドにフォーカスが当たっているかどうかで、キーボードの閉じる閉じないを制御する必要がある、という話をおさらいします。
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つになります)
その上でフォーカスが当たっているフィールド用の「閉じる」しか機能しません。
こういうことを避けるため、フォーカスが当たっているかどうかによって、閉じるを付ける付けないを制御する必要があります。
ところが、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()
}
}
初期状態で「次のページ」をタップすると以下のように期待通りに遷移します。
ですが、テキストフィールドにフォーカスが当たっている状態(つまりキーボードが開いた状態)で「次のページ」をタップすると、以下のように遷移します。
つまり、画面上部に何か余分なスペースが入ってしまいます。
これが、どういうわけかテキストフィールドのフォーカスによらず閉じるを付けた場合は、キーボードが開いた状態でも同じように期待通りに(つまり余分なスペースが入らずに)動作します。
詳細な原因は結局分かっていませんが、ひとまず遷移する前にフォーカスを外せば良いということが分かりました。
ただし、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()
}
}
リンクではなくボタンにして、ボタンのアクションとして「フォーカスを外した上で、遷移を発火させる」というような変更になります。
ここまで来ると、テキストフィールド側で制御するのはどうなのか、という気がしますね。
これでもダメなケースがありました。
このビューがシートに載っている場合はうまく行きません。
シートからナビゲーションで隣に遷移するというのはどうか、という議論はあると思いますが。
struct FrameView: View {
@State var showSheet: Bool = false
var body: some View {
Button("シート表示") {
showSheet.toggle()
}
.sheet(isPresented: $showSheet) {
ContentView()
}
}
}
ContentView
が以上のようなFrameView
からシートで表示されるとすると、やはり画面上部に変なスペースが生まれます。
ちなみにフォーカスの有無をチェックしないバージョンのテキストフィールドを使えば特に問題なく遷移します(問題はその場合、閉じるボタンが複数付いちゃう、ということです)。
シートに載せる場合、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 {
←この行が要らないので、ここを削除すれば問題なく表示されます。
色々テストをすると、@FocusState
の変数と連動して「閉じる」を表示しようとするとダメ、という感じのようです。
「閉じる」を表示するかどうか、というshowKeyboardCloseButton
変数を用意し、
-
@FocusState
の変数にそれを連動させるようにする - ただし
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()
}
}
最初に戻って、もしテキストフィールド側に「キーボードを閉じる」ボタンを付けるとした場合でかつ、シート上に表示されるビューでもうまく動かそうとすると、
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
のような外部から制御する変数を渡します。
基本的には自分自身のフォーカスの有無で制御しますが、showKeyboardCloseButton
がfalse
なら、フォーカスの有無によらず「閉じる」は表示しません。
呼び出し側では以下のようにしてやります。
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()
}
}
- 通常時は
showKeyboardCloseButton
はtrue
にしておく -
NavigationStack
での遷移前にshowKeyboardCloseButton
をfalse
にする -
NavigationStack
から戻ってきたとき、showNextPage
がfalse
になるので、そこを監視してshowKeyboardCloseButton
をtrue
に戻す(戻さないと二度と「閉じる」が表示されなくなる)。
なんか嫌な雰囲気が漂いますね。
カスタムテキストフィールド側に@Binding
変数を用意して、それが@FocusState
と連動するようにしようとしても、複数のテキストフィールドで@Binding
の変数を分けない限りテキストフィールド間が連動してしまい「閉じる」ボタンが複数表示されてしまいますので、こんなことが必要になります。
ちなみにこの話は、iOS 17.1.2の実機で確認しています。
@FocusState
のバグのような気もしないでもないので、そのうちこんなことしなくて良くなるのかも知れません。
色々試した結果、カスタムコントロール側に「キーボードを閉じる」機能を付けるのは得策ではない、と判断しました。
カスタムコントロール側ではどのケースで「キーボードを閉じるボタン」を表示すべきか、判断できなさそうです。
理屈では「自分にフォーカスが当たっていたら」キーボードを閉じるボタンを表示すれば良いのですが、現状「自分にフォーカスが当たっていたら」という状態を正しく取得できないケースがあります。
なんとなくですが、モディファイアという仕組みを使っている以上、仕方ないのかなという気がしています。
色々試した結果、カスタムコントロール側に「キーボードを閉じる」機能を付けるのは得策ではない、と判断しました。
こうでもないのかな。
どうすっかな…。
複数のテキストフィールドがあるビューを部品化すると、画面側からキーボードをどうやって閉じるのか、という問題が出てきてしまいます。
複数のテキストフィールドがあるビューを部品化すると、画面側からキーボードをどうやって閉じるのか
試してみました。
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つ、という扱いにする。どのフィールドか?みたいなことはしない。 - 画面側でキーボードを閉じるボタンを用意する
無事キーボードを閉じられるようです。
その上で、シート上からナビゲーション遷移をしても変なスペースが生まれませんし、「閉じる」ボタンが複数個生じるということもないようです。
ということで「画面側で閉じるボタンを用意する」ということでいいような気がします。
ということで「画面側で閉じるボタンを用意する」ということでいいような気がします。
ただ?これちょっと思うのですが「画面側」というときの「画面」とは何か、という問題がありますね。
小さいコントロールも、スクリーンを丸々覆うビューも、どちらもView
で、明確な差異がないように思います。
TabView
で切り替えられる内側のビューは「画面」なのか。TabView
を乗っけている側で「閉じる」を付けるべきなのか、それとも切り替えられるビュー側で「閉じる」を付けるべきなのか。
テキストフィールドがあるのは内側のビューなので内側に付けるべきじゃないかとは思うのですが、これってコントロールに付けるのと何が違うの?どう判断するの?という話になるような気もします。
例えば、TabView
の切り替えられる側ビューに「閉じる」を付けておいたものの、TabView
側に何かテキストフィールドが追加されたら、「閉じる」をTabView
側に付け替えるの?ってなるでしょう。
遷移先のビューに影響を与える、という状況が引き起こしているので、それが直れば良いのですが…
(というか、キーボードに標準で「閉じる」を付けておいて欲しいんだけども)
まだ苦労しています。
フォーカスが当たったときに、仮想キーボード?スクリーンキーボード?に「閉じる」ボタンが付くのですが、画面が表示されたあと最初にフォーカスが当たったときに表示されないことがあります。
表示されることもあります。
次に別のテキストフィールドにフォーカスを当てるともう表示されます。それ以降は最初のフィールドにフォーカスを当てても表示されます。
感触的には、.toolbar
のない画面から.toolbar
のある画面に来た直後が表示されないような気がしますが、そうじゃないのかも知れません。
感触的には、
.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の実機で確認しています。
以下のscrapに書きましたが、ちょっともうこれは.toolbar(placement: .keyboard)
を使ってる場合じゃないんじゃないかという気がしてきました。
Stack Overflowに、100% Pure SwiftUIかつiOS 14でもやってみました、みたいな投稿がありました。
で、これをベースにカスタムモディファイアを作ってみました。
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)
}
}
}
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))
}
}
ちなみに「このモディファイアを使うなら、もしかしてコントロール側につけてもOKかも?」と思ったのですが、そうは問屋が卸してくれませんでした。
このモディファイアを付けた部分の最下部にバーが表示されるため、コントロール側に付けるとコントロールの直下に入ってしまいます。
なので画面側につける、という制約は変わらず。
このモディファイア、画面下部にテキストフィールドがある場合に、フィールドを隠してしまいますね。
ちょっと見直しが必要。
見直して以下のようになりました。
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
での背面のビューになっていてユーザからは見えないので、幅とか色とかの見栄えは気にしません。
このモディファイア、VStack
やZStack
をビュー階層に追加してしまうので、Section
などに対して使うとList
やForm
が崩れます。
List
やForm
に対して使えば良いのですが、そうできない場合があります。
(やり方の問題と言えばそうなのかも知れませんが)
例えば、Section
のレベルで部品化していたとします。
その部品の中からNavigationLink
で遷移し、遷移先でテキストフィールドがあるとします。
このケースでは外側のList
やForm
からだと、Section
直下のフォーカスは拾えますが、遷移先のフォーカスは拾えません。
それならばSection
に.keyboardCloseButton
を追加すればいいんじゃ?ということで追加すると、冒頭の通り、間にVStack
が入ってしまうのでList
が崩れます。
色々考えましたが、
その部品の中から
NavigationLink
で遷移し、遷移先でテキストフィールドがあるとします。
この、部品の中から遷移させるというのが良くないんじゃないかという気がします。
部品の中で遷移するボタンが押されたら、それを親ビューに通知してそこから遷移させるべき、という感じでしょうか。中から遷移させると、表がずれて見えるのもそのせいの気がしてきました。
部品化の外側で制御することになってしまいましたが、構成を考えるとこのほうが正しいように思えます。
動作も期待通りになりました。
さらに問題が。
場合によっては、キーボードの上のツールバーに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」「キャンセル」が表示されます。
ちなみに、このキャンセルボタンタップ時のクリア処理も以下のiOS17の不具合の影響を受けているので、クリア処理はちょっと見直す必要がありますね。iOS 17.2.1でも直ってません。
キャンセルボタンの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))
}
}
}
}
}
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))
}
}
}
}
}
しかしこの「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()
}
}
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))
}
}
}
}
}