SwiftUI: iOSで入力補助メニューを表示する
概要
テキストフィールドに文字を何文字か打つと、前方一致検索などで既存のデータから入力候補をメニューとして表示する、という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
と、実際の候補表示用のList
をZStack
で重ねます。
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
の.overlay
でPopupMenuView
を表示します。
候補が空の場合は表示せず、候補がある場合のみ表示します。
この候補有無で.overlay
の有無を切り替えることも可能なのですが、それをするとテキスト入力が途中で中断されてしまいます(フォーカスが外れます)。
このため非表示の場合は.opaque
で透明にしています。
最初は.overlay
でなくてZStack
でやってたのですが、TextField
ではなくメニューを重ね表示する側のビューにZStack
を付ける必要があり、あまり直感的でなかったのでこのようにしました。
ただし一方で、描画順によってはポップアップメニューが他のビューの下に潜ってしまいます。
今回の例で言うと、青い背景のビューはその上のテキストフィールドよりも後から描かれるので、そのままだと青い背景の下にポップアップメニューが潜ります。
このため、各ビューにzIndex
でZオーダーを指定しています。(テキストフィールドのzIndex
を潜り先よりも大きくすると潜らなくなります)
最後に
こういうコンポーネントを作るのは、SwiftUIの場合は非常に楽しいですね。HyperCardみたいです。
一方でやっぱりもっと色々と、標準で充実してもらいたいという気はします。
-
もうちょっと言うと、この
.textInputSuggestions(_:)
は前方一致検索とかいうことに使うものではないような、使っても良いような、うーんどうなの?という感じなのです。Appleのサンプルでは、メニューに場所の名前などを表示してそれを選ぶとその場所の住所が入力される、というようなことに使っています。自分は今ひとつ、使いどころがピンと来ていません。 ↩︎
Discussion
メニューから選択されたかどうかをフォーカスの有無で管理しているのですが、フォーカスを使っている処理が他にもある場合、都合が悪いですね。
フォーカス以外の変数を使うようにしたほうが良さそうです。
と言っても、あるonChange
ハンドラ内(A)で複数の@State
変数(例えばx
,y
)を同時変更して別のonChange
ハンドラ(B)がトリガーされる場合、B内でx
,y
がともに変更後の値になっているという可能性は低く(なっている場合もある)、2つの状態変数を使うのはたぶんうまくいきません。何か、入力中のテキストとメニュー選択されたかどうかを同時に扱う構造体をEquatable
で定義して、それを使う方が良いかも知れません。→うまく行きました。コードを修正しておきました。