🦋
iOSカスタムキーボードをSwiftUIで作る
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. 🌐キーボード切替ボタンを実装する
UIInputViewController
のhandleInputModeList(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キーのようなボタンを実装
タップした時は一度だけ削除/改行が行われ、長押ししている時は連続して削除/改行が行われるようなボタンを実装する方法。以下を参照。
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