⌨️

SwiftUI: iOSで入力補助メニューを表示する

2024/09/26に公開
1

概要

テキストフィールドに文字を何文字か打つと、前方一致検索などで既存のデータから入力候補をメニューとして表示する、というUIがあります。
今回、SwiftUIのTextField.textInputSuggestions(_:)というモディファイヤが導入された[1]、と喜んだのですが、macOS 15以降のみでiOSはなし、ということでした…。

では実際のところ、世間の方々はどんな感じでやっているのかと調べてはみたものの、あんまりそれっぽいコードが見つかりませんでした。

今回、試行錯誤しつつ、それっぽい感じのものを作りましたのでコードと合わせてここでご紹介します。
(動作はXcode 16.0+iOS 18.0で検証しています)

実際の動作の様子

使い方

上記の動作デモ画面のコードは以下の通りです。

struct TestView: View {
    enum FocusTarget {
        case fistField
        case secondField
        case dummyField
    }
    @State var text1: String = ""
    @State var text2: String = ""
    @State var dummy: String = ""
    
    @FocusState var focusedField: FocusTarget?
    var candidates: [String] = [
        "かたづけ",
        "かまあげ",
        "からあげ",
        "からおけ",
        "からて",
        "からてがた",
        "からぶり",
        "さけちゃづけ",
        "さくらんぼ",
        "さつまあげ",
        "さふぁりぱーく",
        "たしかに",
        "たしなめる",
        "たいちゃづけ",
    ]
    
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            VStack(alignment: .leading) {
                Text("コンテンツを表示するビュー\nなんでもいいんですが")
                Text("\nこのビューの上に重ねて表示するというわけです\n")
                TextField("ダミー入力フィールド", text: $dummy, prompt: Text("メニュー用のビューがインプットを食ってしまわないかチェックする用"), axis: .vertical)
                    .lineLimit(3)
                    .focused($focusedField, equals: .dummyField)
                Spacer()
            }
            .background(.green)
            .zIndex(0)
            TextField("入力フィールド1", text: $text1, prompt: Text("上に補完候補が出るテキストフィールド"))
                .textFieldStyle(.roundedBorder)
                .focused($focusedField, equals: .fistField)
                .padding()
                .inputSuggestion(focusedField: $focusedField, equals: .fistField, popupDirection: .up, text: $text1, searchCandidate: { inputText in
                    let matched = self.candidates.filter( { $0.starts(with: inputText) } )
                    return matched
                })
                .zIndex(1)
                .onChange(of: text1) { (oldValue, newValue) in
                    print("text1 changed: \(newValue), focus: \(focusedField)")
                }
                
            TextField("入力フィールド2", text: $text2, prompt: Text("下に補完候補が出るテキストフィールド"))
                .textFieldStyle(.roundedBorder)
                .focused($focusedField, equals: .secondField)
                .padding()
                .inputSuggestion(focusedField: $focusedField, equals: .secondField, popupDirection: .down, text: $text2, searchCandidate: { inputText in
                    let matched = self.candidates.filter( { $0.starts(with: inputText) } )
                    return matched
                })
                .zIndex(1)
                
                
            VStack(alignment: .leading) {
                Text("コンテンツを表示するまた別のビューです\nやはりなんでもいいです")
                
                Text("\nこのビューの上に重ねて表示します")
                Spacer()
            }
            .background(.blue)
            .zIndex(0)
        }
    }
}

TextFieldに付けたモディファイヤ.inputSuggestionが、この入力候補表示を制御します。

引数は以下の通りです。

  • focusedField: TextFieldのフォーカスを制御する変数(@FocusStateで宣言し、TextField.focusedで修飾)
  • equals:: このフィールドにフォーカスが当たっている場合にfocusedFieldが指す値。TextField.focusedで指定したもの
  • popupDirection: 入力候補をTextFieldの上(.up)に出すか下(.down)に出すかを指定
  • text: TextFieldのテキスト入力値を保持する変数。原則TextFieldに渡すバインディングと同じものを渡す
  • searchCandidate: テキスト入力文字列値を使って、入力候補の文字列配列を返すクロージャ

最後のクロージャですが、この例ではビューの持つcandidates[]から前方一致で合致する候補を探して返しています。

モディファイヤ.inputSuggestionのコード

ポップアップ表示するメニューのコード

struct PopupMenuView: View {
    let title: LocalizedStringKey = "候補"
    var menuItems: [String]
    @Binding var selectedItem: String?
    @Binding var close: Bool
    var popupDirection: VerticalDirection
    var maxHeight: CGFloat = 300
    
    var body: some View {
        let totalHeight: CGFloat = UIFontMetrics.default.scaledValue(for: (54 * CGFloat(self.menuItems.count)) + 46)
        let height = maxHeight < totalHeight ? maxHeight : totalHeight
        ZStack {
            RoundedRectangle(cornerRadius: 8)
                .fill(Color(uiColor: UIColor.systemBackground))
                .shadow(radius: 8)
                .padding(.horizontal, 40)
            List {
                Section(content: {
                    ForEach(self.menuItems.indices, id: \.self) { itemIdx in
                        let itemText = self.menuItems[itemIdx]
                        Button(action: {
                            self.selectedItem = itemText
                        }, label: {
                            Text(itemText)
                                .padding(.leading, 4)
                        })
                        .foregroundStyle(Color.primary)
                    }
                    .listSectionSeparator(.hidden)
                }, header: {
                    HStack {
                        Text(self.title)
                            .font(.subheadline)
                        Spacer()
                        Button(action: {
                            self.close.toggle()
                        }, label: {
                            Image(systemName: "xmark.circle.fill")
                                .foregroundStyle(Color(uiColor: UIColor.tertiaryLabel))
                        })
                    }
                })
            }
            .listStyle(.plain)
            .padding(.horizontal, 40)
            .frame(height: height)
        }
        .offset(y: UIFontMetrics.default.scaledValue(for: (self.popupDirection == .up ? -(height/2 + 80) : height/2 + 30)))
    }
}

説明

メニューは、TextField.overlayで描きます(.overlayのコード自体はモディファイヤ側にあります)。
そのままだとTextFieldの真上に描かれるので、.offsetでずらします。
背景用のRoundedRectangleと、実際の候補表示用のListZStackで重ねます。
List.frameでサイズを指定しないと、広がれるだけ広がろうとしますので、候補数を勘案した高さを計算して指定しています。
またテキストフィールドは、入力中に漢字変換ウィンドウなどを上下に表示しますので、候補メニューはテキストフィールドからいくぶんずらして表示してやる必要があります。

モディファイヤのコード

struct InputSuggestionModifier<FocusTargetT: Hashable>: ViewModifier {
    @State var matchedCandidates: [String] = []
    var popupDirection: VerticalDirection
    var maxHeight: CGFloat
    var focusedField: FocusState<FocusTargetT?>.Binding
    var focusTarget: FocusTargetT
    @Binding var text: String
    @State var selectedCandidate: String? = nil
    @State var closeMenu: Bool = false
    var searchCandidate: (String) -> [String]
    
    enum MenuState {
        case selected
        case ios17bugHandled
        case close
        case open
    }
    @State var menuState: MenuState = .close
    
    func body(content: Content) -> some View {
        content
            .overlay {
                PopupMenuView(menuItems: self.matchedCandidates, selectedItem: $selectedCandidate, close: $closeMenu, popupDirection: self.popupDirection, maxHeight: self.maxHeight)
                    .opacity(self.matchedCandidates.isEmpty ? 0.0 : 1.0)
            }
            .onSubmit {
                self.matchedCandidates.removeAll()
                self.menuState = .close
            }
            .onChange(of: text) { (oldValue, newValue) in
                if self.menuState == .selected {
                    //候補メニューを閉じる
                    self.matchedCandidates.removeAll()
                    self.menuState = .ios17bugHandled
                }
                else if self.menuState == .ios17bugHandled {
                    self.menuState = .close
                }
                // 打った場合(およびiOS 17以降のTextFieldの編集バグ対応のonChangeタイミング1)
                else {
                    //テキストを全削除した場合、
                    if newValue.isEmpty {
                        //候補メニューを閉じる
                        self.matchedCandidates.removeAll()
                        self.menuState = .close
                    }
                    else {
                        //テキストが空でなければ候補一覧から行頭マッチングして探す
                        let listItems = self.searchCandidate(newValue)
                        //候補が1個かつ打ったテキストと同じ場合は候補メニューは閉じる
                        if listItems.count == 1 && listItems[0] == newValue {
                            self.matchedCandidates.removeAll()
                            self.menuState = .close
                        }
                        //候補が複数あるか、1つあっても一致していない場合は候補メニューに候補を表示する
                        else {
                            self.matchedCandidates = listItems
                            self.menuState = .open
                        }
                    }
                    
                }
                self.selectedCandidate = nil
            }
            .onChange(of: selectedCandidate ?? "") { (oldValue, newValue) in
                //候補をメニューで選択した場合、newValueは選択された値が入る
                if !newValue.isEmpty {
                    // iOS 17以降のTextFieldの編集バグ対応
                    self.text += " "   // ...onChangeタイミング1
                    Task { @MainActor in
                        self.text = newValue // ...onChangeタイミング2
                        
                    }
                    self.selectedCandidate = nil
                    
                    //フォーカスを外す
                    self.menuState = .selected
                }
            }
            .onChange(of: focusedField.wrappedValue) { (oldValue, newValue) in
                if newValue != self.focusTarget {
                    self.matchedCandidates.removeAll()
                    self.menuState = .close
                }
                
            }
            .onChange(of: closeMenu) { (oldValue, newValue) in
                if closeMenu {
                    self.matchedCandidates.removeAll()
                    self.menuState = .close
                    closeMenu = false
                }
            }
    }
}

extension View {
    func inputSuggestion<FocusTargetT: Hashable>(focusedField: FocusState<FocusTargetT?>.Binding, equals: FocusTargetT, popupDirection: VerticalDirection, maxHeight: CGFloat = 200, text: Binding<String>, searchCandidate: @escaping (String) -> [String]) -> some View {
        self.modifier(InputSuggestionModifier<FocusTargetT>(popupDirection: popupDirection, maxHeight: maxHeight, focusedField: focusedField, focusTarget: equals, text: text, searchCandidate: searchCandidate))
    }
}

説明

TextField.overlayPopupMenuViewを表示します。
候補が空の場合は表示せず、候補がある場合のみ表示します。
この候補有無で.overlayの有無を切り替えることも可能なのですが、それをするとテキスト入力が途中で中断されてしまいます(フォーカスが外れます)。
このため非表示の場合は.opaqueで透明にしています。

最初は.overlayでなくてZStackでやってたのですが、TextFieldではなくメニューを重ね表示する側のビューにZStackを付ける必要があり、あまり直感的でなかったのでこのようにしました。
ただし一方で、描画順によってはポップアップメニューが他のビューの下に潜ってしまいます。

今回の例で言うと、青い背景のビューはその上のテキストフィールドよりも後から描かれるので、そのままだと青い背景の下にポップアップメニューが潜ります。
このため、各ビューにzIndexでZオーダーを指定しています。(テキストフィールドのzIndexを潜り先よりも大きくすると潜らなくなります)

最後に

こういうコンポーネントを作るのは、SwiftUIの場合は非常に楽しいですね。HyperCardみたいです。

一方でやっぱりもっと色々と、標準で充実してもらいたいという気はします。

脚注
  1. もうちょっと言うと、この.textInputSuggestions(_:)は前方一致検索とかいうことに使うものではないような、使っても良いような、うーんどうなの?という感じなのです。Appleのサンプルでは、メニューに場所の名前などを表示してそれを選ぶとその場所の住所が入力される、というようなことに使っています。自分は今ひとつ、使いどころがピンと来ていません。 ↩︎

Discussion

kabeyakabeya

メニューから選択されたかどうかをフォーカスの有無で管理しているのですが、フォーカスを使っている処理が他にもある場合、都合が悪いですね。
フォーカス以外の変数を使うようにしたほうが良さそうです。

と言っても、あるonChangeハンドラ内(A)で複数の@State変数(例えばx,y)を同時変更して別のonChangeハンドラ(B)がトリガーされる場合、B内でx,yがともに変更後の値になっているという可能性は低く(なっている場合もある)、2つの状態変数を使うのはたぶんうまくいきません。

何か、入力中のテキストとメニュー選択されたかどうかを同時に扱う構造体をEquatableで定義して、それを使う方が良いかも知れません。

→うまく行きました。コードを修正しておきました。