🦋

iOSカスタムキーボードをSwiftUIで作る

2023/08/19に公開

1. UIInputViewControllerにSwiftUIのViewを差し込む

struct KeyboardView: View {
    var body: some View {
        // ・・・省略
    }
}

class KeyboardViewController: UIInputViewController {
    override public func viewDidLoad() {
        super.viewDidLoad()

        let keyboardView = KeyboardView()
        let hostingController = UIHostingController(rootView: keyboardView)
        addChild(hostingController)
        view.addSubview(hostingController.view)
        hostingController.didMove(toParent: self)
        hostingController.view.backgroundColor = UIColor.clear
        hostingController.view.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            hostingController.view.leftAnchor.constraint(equalTo: view.leftAnchor),
            hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
            hostingController.view.rightAnchor.constraint(equalTo: view.rightAnchor),
            hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
    }
}

2. ButtonのイベントをUIInputViewControllerに伝搬する

Helloを入力できるボタン
struct HelloButton: View {
    let onHelloHandler: () -> Void

    var body: some View {
        Button {
            onHelloHandler()
        } label: {
            Text("Hello")
        }
    }
}
KeyboardViewを介してHelloButtonを使用
struct KeyboardView: View {
    @StateObject var viewModel: KeyboardViewModel

    var body: some View {
        HStack {
            HelloButton {
                viewModel.hello()
            }
        }
    }
}

class KeyboardViewModel: ObservableObject {
    let helloHandler: (String) -> Void

    init(helloHandler: @escaping (String) -> Void) {
        self.helloHandler = helloHandler
    }

    func hello() {
        helloHandler("Hello")
    }
}
keyboardViewModelを介してtextDocumentProxyを制御
class KeyboardViewController: UIInputViewController {
    override public func viewDidLoad() {
        super.viewDidLoad()

        let keyboardViewModel = KeyboardViewModel(helloHandler: { [weak self] text in
            self?.textDocumentProxy.insertText(text)
        })
        let keyboardView = KeyboardView(viewModel: keyboardViewModel)
        // 以下省略
    }
}

3. 🌐キーボード切替ボタンの要不要を確認する

viewWillLayoutSubviewsのタイミングで確認できる
class KeyboardViewModel: ObservableObject {
    @Published var needsGlobe: Bool = true

    init() {}
}

class KeyboardViewController: UIInputViewController {
    override public func viewWillLayoutSubviews() {
        keyboardViewModel.needsGlobe = needsInputModeSwitchKey
        super.viewWillLayoutSubviews()
    }
}
needsGlobeに従ってキーボード切替ボタンの表示非表示を判断できる
struct KeyboardView: View {
    @StateObject var viewModel: KeyboardViewModel

    var body: some View {
        HStack {
            if viewModel.needsGlobe {
                GlobeButton()
            }
        }
    }
}

4. 🌐キーボード切替ボタンを実装する

UIInputViewControllerhandleInputModeList(from: UIView, with: UIEvent)UIButtonでしかハンドリングできないため、UIViewRepresentableを使わざるを得ない。

UIButtonをラップしたUIViewRepresentableを実装
struct GlobeButtonOverlay: UIViewRepresentable {
    private let onGlobeHandler: (UIView, UIEvent) -> Void
    private let onTouchHandler: (Bool) -> Void

    init(
        onGlobeHandler: @escaping (UIView, UIEvent) -> Void,
        onTouchHandler: @escaping (Bool) -> Void
    ) {
        self.onGlobeHandler = onGlobeHandler
        self.onTouchHandler = onTouchHandler
    }

    func makeUIView(context: Context) -> UIButton {
        let button = UIButton()
        let action = #selector(context.coordinator.handleInputModeList(from:with:))
        button.addTarget(context.coordinator, action: action, for: .allTouchEvents)
        return button
    }

    func updateUIView(_ uiView: UIButton, context: Context) {}

    func makeCoordinator() -> Coordinator {
        return Coordinator(globeButtonOverlay: self)
    }

    class Coordinator {
        private let globeButtonOverlay: GlobeButtonOverlay

        init(globeButtonOverlay: GlobeButtonOverlay) {
            self.globeButtonOverlay = globeButtonOverlay
        }

        @objc func handleInputModeList(from: UIView, with: UIEvent) {
            globeButtonOverlay.onGlobeHandler(from, with)
            // ボタンをタッチしているかどうかをSwiftUIのViewに伝えるのに必要
            if with.type == .touches, let touch = with.allTouches?.first {
                switch touch.phase {
                case .began:
                    globeButtonOverlay.onTouchHandler(true)
                case .ended, .cancelled:
                    globeButtonOverlay.onTouchHandler(false)
                default:
                    break
                }
            }
        }
    }
}
GlobeButtonOverlayを間接的に使えるGlobeButtonを実装
struct GlobeButton: View {
    @State var isPressed: Bool = false
    let onGlobeHandler: (UIView, UIEvent) -> Void

    var body: some View {
        Image(systemName: "globe")
            .frame(width: 32, height: 32)
            .padding(4)
            .cornerRadius(8)
            .opacity(isPressed ? 0.6 : 1.0)
            .overlay {
                GlobeButtonOverlay { from, with in
                    onGlobeHandler(from, with)
                } onTouchHandler: { flag in
                    isPressed = flag
                }
            }
    }
}
GlobeButtonを使う
class KeyboardViewModel: ObservableObject {
    let globeHandler: (UIView, UIEvent) -> Void

    @Published var needsGlobe: Bool = true

    init(globeHandler: @escaping (UIView, UIEvent) -> Void) {
        self.globeHandler = globeHandler
    }
}

struct KeyboardView: View {
    @StateObject var viewModel: KeyboardViewModel

    var body: some View {
        HStack {
            if viewModel.needsGlobe {
                GlobeButton(onGlobeHandler: { from, with in
                    viewModel.globeHandler(from, with)
                })
            }
        }
    }
}

class KeyboardViewController: UIInputViewController {
    override public func viewDidLoad() {
        super.viewDidLoad()

        let keyboardViewModel = KeyboardViewModel(globeHandler: { [weak self] from, with in
            self?.handleInputModeList(from: from, with: with)
        })
        let keyboardView = KeyboardView(viewModel: keyboardViewModel)
        // 以下省略
    }
}

5. フルアクセスの許可を確認する

class KeyboardViewController: UIInputViewController {
    override public func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        viewModel.hasFullAccess = hasFullAccess
    }
}

class KeyboardViewModel: ObservableObject {
    var hasFullAccess: Bool = false

    init() {}
}

6. DeleteキーやReturnキーのようなボタンを実装

タップした時は一度だけ削除/改行が行われ、長押ししている時は連続して削除/改行が行われるようなボタンを実装する方法。以下を参照。

https://zenn.dev/kyome/articles/97bf84b4613b56

7. Shiftキーのようなボタンを実装

iOS標準ソフトウェアキーボードのShiftキーは、タップで一文字だけ大文字にでき、ダブルタップでCapsLock状態に切り替えることができる。このようなボタンをSwiftUIで実装する方法。

Shiftキーの状態を定義
enum ShiftState {
    case off
    case on
    case capsLock

    var systemName: String {
        switch self {
        case .off:
            return "shift"
        case .on:
            return "shift.fill"
        case .capsLock:
            return "capslock.fill"
        }
    }
}
ShiftButtonModelを実装
class ShiftButtonModel: ObservableObject {
    let updateShiftStateHandler: (ShiftState) -> Void

    @Published var shiftState: ShiftState = .off {
        didSet {
            updateShiftStateHandler(shiftState)
        }
    }

    private var cancellables = Set<AnyCancellable>()
    private var isTouching: Bool = false
    private var previousDate: Date?

    init(
        resetShiftStatePublisher: AnyPublisher<Void, Never>,
        updateShiftStateHandler: @escaping (ShiftState) -> Void
    ) {
        self.updateShiftStateHandler = updateShiftStateHandler
        resetShiftStatePublisher.sink { [weak self] in
            self?.resetShiftState()
        }
        .store(in: &cancellables)
    }

    private func resetShiftState() {
        if shiftState == .on {
            shiftState = .off
        }
    }

    func touchDown() {
        if isTouching { return }
        isTouching = true
        if let previousDate, -previousDate.timeIntervalSinceNow < 0.25 {
            shiftState = (shiftState == .capsLock) ? .off : .capsLock
        } else {
            shiftState = (shiftState == .off) ? .on : .off
        }
        previousDate = Date.now
    }

    func touchUp() {
        isTouching = false
    }
}
ShiftButtonを実装
struct ShiftButton: View {
    @StateObject var viewModel: ShiftButtonModel

    var body: some View {
        Image(systemName: viewModel.shiftState.systemName)
            .frame(width: 32, height: 32)
            .padding(4)
            .cornerRadius(8)
            .opacity(viewModel.shiftState == .off ? 1.0 : 0.6)
            .gesture(
                DragGesture(minimumDistance: 0.0, coordinateSpace: .global)
                    .onChanged { _ in
                        viewModel.touchDown()
                    }
                    .onEnded { _ in
                        viewModel.touchUp()
                    }
            )
    }
}

基本的にButtonにタップと長押しのジェスチャーを同時に登録してうまくいくことはないので、ImageをベースにDragGestureを駆使して地味に実装していく。

ShiftButtonを使う
class KeyboardViewModel: ObservableObject {
    let shiftHandler: () -> Void
    var isUpperCase: Bool = false

    private let resetShiftStateSubject = PassthroughSubject<Void, Never>()
    var resetShiftStatePublisher: AnyPublisher<Void, Never> {
        return resetShiftStateSubject.eraseToAnyPublisher()
    }

    init(shiftHandler: @escaping () -> Void) {
        self.shiftHandler = shiftHandler
    }

    func resetShiftState() {
        resetShiftStateSubject.send()
    }
}

struct KeyboardView: View {
    @StateObject var viewModel: KeyboardViewModel

    var body: some View {
        HStack {
            ShiftButton(viewModel: ShiftButtonModel(
                resetShiftStatePublisher: viewModel.resetShiftStatePublisher,
                updateShiftStateHandler: { shiftState in
                    viewModel.isUpperCase = shiftState != .off
                    viewModel.shiftHandler()
                }
            )           
        }
    }
}

class KeyboardViewController: UIInputViewController {
    // 一文字入力した時や入力候補選択をした時などにリセットをリクエストする
    keyboardViewModel.resetShiftState()
}

8. 文字入力候補(Candidates)を実装する

横向きのScrollViewを使えば良いだけなのでそこまで難しくない。

CandidatesViewを実装
struct CandidateButton: View {
    let candidate: String
    let onSelectHandler: () -> Void

    var body: some View {
        Button {
            onSelectHandler()
        } label: {
            Text(candidate)
                .lineLimit(1)
        }
    }
}

struct CandidatesView: View {
    @Binding var candidates: [String]
    let onSelectHandler: (Int) -> Void

    var body: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: 2) {
                ForEach(candidates.indices, id: \.self) { i in
                    CandidateButton(candidate: candidates[i]) {
                        onSelectHandler(i)
                    }
                }
            }
        }
    }
}
CandidatesViewを使う
class KeyboardViewModel: ObservableObject {
    let selectHandler: (Int) -> Void

    @Published var candidates = [String]()

    init(selectHandler: @escaping (Int) -> Void) {
        self.selectHandler = selectHandler
    }

    func updateCandidates(_ candidates: [String]) {
        self.candidates = candidates
    }

    func clearCandidates() {
        candidates.removeAll()
    }
}

struct KeyboardView: View {
    @StateObject var viewModel: KeyboardViewModel

    var body: some View {
        VStack {
            CandidatesView(candidates: $viewModel.candidates) { index in
                viewModel.selectHandler(index)
            }
            .frame(height: 40)
            .frame(maxWidth: .infinity)
        }
    }
}

class KeyboardViewController: UIInputViewController {
    private var candidates = [String]()

    func setCandidates() {
        candidates = // 何かしらのエンジンで文字入力候補のリストを更新
        keyboardViewModel.updateCandidates(candidates)
    }

    func clearCandidates() {
        candidates.removeAll()
        keyboardViewModel.clearCandidates()
    }
}

Discussion