Open4

SwiftUIで画面下部(キーボードの位置)にWheel Pickerを表示する

kotasankotasan

実装

//
//  ContentView.swift
//  testWheelPickerView
//
//  Created by 松本 幸太郎 on 2024/09/15.
//

import SwiftUI

struct WheelPickerField<Item: Hashable>: UIViewRepresentable {
    @Binding var selected: Item
    let items: [Item]
    let title: (Item) -> String
    var placeholder: String

    func makeUIView(context: Context) -> UITextField {
        let textField = UITextField()
        textField.textAlignment = .left
        textField.setContentHuggingPriority(.defaultHigh, for: .vertical)
        textField.placeholder = placeholder

        let picker = UIPickerView()
        picker.delegate = context.coordinator
        picker.dataSource = context.coordinator
        textField.inputView = picker
        return textField
    }

    func updateUIView(_ uiView: UITextField, context: Context) {
        uiView.text = title(selected)
        context.coordinator.items = items

        if let picker = uiView.inputView as? UIPickerView {
            picker.reloadAllComponents()  // Pickerをリロード
            if let selectedIndex = items.firstIndex(of: selected) {
                picker.selectRow(selectedIndex, inComponent: 0, animated: false)
            }
        }
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(selected: $selected, items: items, title: title)
    }

    class Coordinator: NSObject, UIPickerViewDelegate, UIPickerViewDataSource {
        @Binding var selected: Item
        var items: [Item]
        let title: (Item) -> String

        init(selected: Binding<Item>, items: [Item], title: @escaping (Item) -> String) {
            self._selected = selected
            self.items = items
            self.title = title
        }

        func numberOfComponents(in pickerView: UIPickerView) -> Int {
            1
        }

        func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
            items.count
        }

        func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
            title(items[row])
        }

        func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
            selected = items[row]
        }
    }
}

使用例


struct Item: Hashable {
    var name: String
}

struct ContentView: View {
    @State private var selection: Item = .init(name: "BenchPress")
    @State private var itemList: [Item] = [
        .init(name: "BenchPress"),
        .init(name: "Squat"),
        .init(name: "DeadLift"),
        .init(name: "Chinning"),
        .init(name: "SeatedRow"),
        .init(name: "Abdominal"),
        .init(name: "ArmCurl"),
    ]
    @State private var shouldShowAddItemDialog = false
    @State private var newItem: Item = .init(name: "")

    var body: some View {
        VStack {
            WheelPickerField(
                selected: $selection,
                items: itemList,
                title: \.name,
                placeholder: "Item"
            )
            .padding(4)
            .background(.tertiary)

            Button("Add Item", systemImage: "plus") {
                shouldShowAddItemDialog = true
            }
        }
        .alert("Add Item", isPresented: $shouldShowAddItemDialog) {
            TextField("new item", text: $newItem.name)

            Button("Add") {
                addToList()
            }
            .disabled(newItem.name.isEmpty)

// パターンA 🙆
// 単にもう1つのボタンがある時,`追加ボタン`は正常に動作する
//            Button("Cancel") {
//            // ...
//            }

// パターンB 🙅
// `role: .cancel`のボタンがある時,`追加ボタン`を押下するとpickerの選択肢として反映される場合とされない場合がある. 要調査
//            Button("Cancel", role: .cancel) {
//              // ...
//            }
        }

    }

    private func addToList() {
        if !newItem.name.isEmpty {
            itemList.append(.init(name: newItem.name))
            newItem.name = ""
        }
    }
}


#Preview {
    ContentView()
}
kotasankotasan

Add Buttonが正常に動作する場合

Add Buttonが正常に動作しない場合(Cancel Buttonを追加した場合)

kotasankotasan

👆のバグはどうやらWheelPickerは関係ない.
iOS 17の時に発生するalert付近の問題らしい.
(iOS 18 simulatorでは問題を再現できなかった.)
また会社の先輩から,別のボタンを追加せずとも「追加ボタン」のroleを.destractiveにするだけで,iOS17なら問題を再現できるとご指摘いただいた.
データ競合の問題かと考え,Xcode16.0 RC版でstrict concurrency: completeでビルドしてもコンパイルエラーは起きなかった.

現状👇のコードが問題の最小構成.

import SwiftUI

struct ContentView: View {
    @State private var itemList: [String] = [
       "BenchPress",
       "Squat",
       "DeadLift",
       "Chinning",
       "SeatedRow",
       "Abdominal",
       "ArmCurl",
    ]
    @State private var shouldShowAddItemDialog = false
    @State private var newItem = ""

    var body: some View {
        VStack {
            ForEach(itemList, id: \.self) { item in
                Text(item)
            }
            Button("Add Item", systemImage: "plus") {
                shouldShowAddItemDialog = true
            }
        }
        .alert("Add Item", isPresented: $shouldShowAddItemDialog) {
            TextField("new item", text: $newItem)

            Button("Add", role: .destructive) {
                if !newItem.isEmpty {
                    itemList.append(newItem)
                    newItem = ""
                }
            }
            .disabled(newItem.isEmpty)
        }
    }
}

#Preview {
    ContentView()
}