🦁

ネストされたnavigationDestinationとTextFieldの数値入力のバグ

2024/09/28に公開1

問題と検証

以下のコードで添付GIFのように数値の入力が適切にできない

  • 全桁を削除して空欄にできない
  • 小数点が入力できない
  • leading zeroが自動で削除される

(iOS 18, Xcode 16.0.0 RC)

struct ContentView: View {
    @State private var path: NavigationPath = .init()
    @State private var department: Department = .init()

    var body: some View {
        NavigationStack(path: $path) {
            Button("Visit Store") {
                let newStore: Store = .init()
                department.stores.append(newStore)
                path.append(newStore)
            }
            .navigationDestination(for: Store.self) { store in
                let storeIndex = department.stores.firstIndex { $0.id == store.id }!
                Button("See Item") {
                    let newItem: Item = .init()
                    department.stores[storeIndex].items.append(newItem)
                    path.append(newItem)
                }
                .navigationDestination(for: Item.self) { item in
                    VStack {
                        let itemIndex = department.stores[storeIndex].items.firstIndex { $0.id == item.id }!
                        Text("price")
                        TextField(
                            "",
                            value: $department.stores[storeIndex].items[itemIndex].price,
                            format: .number
                        )
                        .keyboardType(.decimalPad)
                    }
                }
            }
        }
    }
}

@Observable
final class Department {
    var stores: [Store] = []
}

struct Store: Hashable {
    var id: UUID = .init()
    var items: [Item] = []
}

struct Item: Hashable {
    var id: UUID = .init()
    var price: Float = 100
}

以下のようにnavigationDestinationを介さない簡単な例では上述の問題は発生しない

struct ContentView: View {
    @State private var price = 0.0

    var body: some View {
        TextField("", value: $price, format: .number)
            .keyboardType(.decimalPad)
    }
}

また,以下のように内側のnavigationDestinationをNavigationLinkに置き換え,ネストを解消すると問題は再現しない.ただしこの書き方では実際のコードではnavigationDestinationが長大になってしまい,必要以上に状態が共有されてしまうため望ましくない.

struct ContentView: View {
    @State private var path: NavigationPath = .init()
    @State private var department: Department = .init()

    var body: some View {
        NavigationStack(path: $path) {
            Button("Visit Store") {
                let newStore: Store = .init()
                department.stores.append(newStore)
                path.append(newStore)
            }
            .navigationDestination(for: Store.self) { store in
                let storeIndex = department.stores.firstIndex { $0.id == store.id }!
                NavigationLink("See Item") {  // navigationDestination(...)のネストではなくNavigationLinkを用いると問題ない
                    let newItem: Item = .init()
                    department.stores[storeIndex].items.append(newItem)
                    let itemIndex = department.stores[storeIndex].items.firstIndex { $0.id == newItem.id }!
                    return VStack {
                        Text("price")
                        TextField(
                            "",
                            value: $department.stores[storeIndex].items[itemIndex].price,  // ここでTextFieldが必要とする以上の情報に触れられるのが落ち着かない.
                            format: .number
                        )
                        .keyboardType(.decimalPad)
                    }
                }
            }
        }
    }
}

swift-developpers-japanで相談してみると,親切な方が次のコードを試すように教えてくださった.
ItemViewを分ければ状態の変更が分断されるので問題ないのではないか,と.
しかし,次のコードをFloatにした場合,精度の影響で一桁入力するだけで数桁の近似値が反映されることがある.

struct ItemView: View {
    @Binding var department: Department

    var storeIndex: Int
    var itemIndex: Int

    var body: some View {
        VStack {
            Text("price")
            TextField(
                "",
                value: $department.stores[storeIndex].items[itemIndex].price,
                format: .number
            )
            .keyboardType(.decimalPad)
        }
    }
}

struct ContentView: View {
    @State private var path: NavigationPath = .init()
    @State private var department: Department = .init()

    var body: some View {
        NavigationStack(path: $path) {
            Button("Visit Store") {
                let newStore: Store = .init()
                department.stores.append(newStore)
                path.append(newStore)
            }
            .navigationDestination(for: Store.self) { store in
                let storeIndex = department.stores.firstIndex { $0.id == store.id }!
                Button("See Item") {
                    let newItem: Item = .init()
                    department.stores[storeIndex].items.append(newItem)
                    path.append(newItem)
                }
                .navigationDestination(for: Item.self) { item in
                    let itemIndex = department.stores[storeIndex].items.firstIndex { $0.id == item.id }!
                    ItemView(department: $department, storeIndex: storeIndex, itemIndex: itemIndex)
                }
            }
        }
    }
}

挙動を比較する

正常な動作

入力中

  • 無効な入力値(例:空欄,小数点が二箇所にある,末尾にある)であってもUIには反映される
  • Bindされた変数は,入力値が有効なときにのみ更新される.

入力完了時(エンターキーを押下したとき, focusを外したとき)

  • 入力値が有効なら,textを整形(例:1.0→1, .5→0.5)する.
  • 入力値が無効なら,最後の有効な入力値を整形した値をUIに反映(例:0.3.4→0.3, .2.5→0.2)する

異常な動作

  • 入力中,UIに反映する前の時点で,入力値が数値として有効か確認し,整形されている.
    この挙動の問題は,例えば3.14と入力したい時,途中で3. となるが,これを3に整形すると小数点が打てないところにある.

先述のswift-developpers-japanで,次のことを教えていただいた.
navigationDestination の destination ブロックは特別な挙動をするらしい.
いわゆる状態変化があったときのbodyの再評価以外のタイミングで再評価されている.
let _ = Self._printChanges()で確認するとunchangedなのに呼ばれている)
TextField は同じ view location (id) のものが再評価されるとその時点での値でformatが走るが,TextFieldの意図しないタイミングでformatされる場合があり、navigation destiation もその一つ.

対策

A. SwiftUI.TextFieldにはStringをBindし,onAppear, onChangedで有効な文字を変換する.
B. NavigationかTextFieldのどちらかをUIKitで実装する.

今回,自分の開発においては細やかに挙動を指定したかったためBを選んだが,以下に簡単なAの例を挙げる.

struct ContentView: View {
    @State private var value: Float = 0
    @FocusState var isTextFieldFocused: Bool
    var body: some View {
        VStack {
            Text(value.formatted())
            CustomTextField(value: $value)
                .focused($isTextFieldFocused)

            Button("off focus") {
                isTextFieldFocused = false
            }
        }
    }
}

struct CustomTextField: View {
    @Binding var value: Float
    @State private var text: String = .init()
    @FocusState var isTextFieldFocused: Bool

    var body: some View {
        TextField("", text: $text)
            .focused($isTextFieldFocused)
            .onAppear {
                text = .init(value)
            }
            .onChange(of: text) { _, newText in
                refreshValue(with: newText)
            }
            .onChange(of: isTextFieldFocused) { _, _ in
                // 入力完了時にもfocusが外れるため呼ばれる
                text = .init(value)
            }
    }

    private func refreshValue(with text: String) {
        if let value = Float(text) {
            self.value = value
        }
    }
}

よりSwiftUIらしいAPIを目指す場合,以下のようにも書ける.

struct ContentView: View {
    @State private var value: Float = 0
    @FocusState var isTextFieldFocused: Bool
    var body: some View {
        VStack {
            Text(value.formatted())
            FormattingTextField("", value: $value, format: .number, prompt: Text("a"))
                .focused($isTextFieldFocused)

            Button("off focus") {
                isTextFieldFocused = false
            }
        }
    }
}

struct FormattingTextField<FormatStyle, Input>: View where FormatStyle: ParseableFormatStyle, Input == FormatStyle.FormatInput, FormatStyle.FormatOutput == String {
    let titleKey: LocalizedStringKey
    @Binding var value: Input
    let formatStyle: FormatStyle
    let prompt: Text?

    @FocusState var isTextFieldFocused: Bool
    @State private var text: String = .init()

    init(
        _ titleKey: LocalizedStringKey,
        value: Binding<Input>,
        format: FormatStyle,
        prompt: Text? = nil
    ) {
        self.titleKey = titleKey
        self._value = value
        self.formatStyle = format
        self.prompt = prompt
    }

    var body: some View {
        TextField(titleKey, text: $text, prompt: prompt)
            .focused($isTextFieldFocused)
            .onAppear {
                text = formatStyle.format(value)
            }
            .onChange(of: text) { _, newText in
                refreshValue(with: newText)
            }
            .onChange(of: isTextFieldFocused) { _, _ in
                // 入力完了時にもfocusが外れるため呼ばれる
                text = formatStyle.format(value)
            }
    }

    private func refreshValue(with text: String) {
        if let newValue = try? formatStyle.parseStrategy.parse(text) {
            value = newValue
        }
    }
}

↓無効な文字(空欄)もUIに反映される.

↓focusを外した時にUIのtextを整形する

株式会社ゆめみ

Discussion

kotasankotasan

実装に問題,考慮漏れがありましたらご指摘いただけると喜びます.