MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

SwiftUI 自定义键盘与输入视图

2021-11-192.8k 阅读

SwiftUI 自定义键盘基础概念

键盘的组成与输入原理

在 iOS 开发中,键盘是用户与应用程序进行文本输入交互的重要组件。传统的 UIKit 框架中,键盘是系统提供的标准视图,应用程序通过特定的代理方法和通知来与之交互。而在 SwiftUI 中,虽然也可以利用系统键盘进行常规输入,但当我们需要一些特殊的输入需求,如特定格式的数字输入、自定义符号输入等,就需要自定义键盘。

一个完整的键盘通常由按键、键帽、键盘布局等部分组成。当用户点击按键时,系统会捕获这个点击事件,并将对应的字符或操作传递给当前聚焦的输入视图。输入视图负责接收这些输入并进行相应的处理,比如显示在文本框中、更新数据模型等。

SwiftUI 中的输入机制

SwiftUI 提供了 TextFieldTextEditor 等视图来处理用户输入。当这些视图获得焦点时,系统会自动弹出相应的键盘。对于 TextField,主要用于单行文本输入,而 TextEditor 则适用于多行文本输入。

例如,创建一个简单的 TextField

struct ContentView: View {
    @State private var text = ""
    var body: some View {
        TextField("Enter text", text: $text)
    }
}

当用户点击 TextField 时,系统键盘弹出,用户输入的内容会实时更新 text 这个 @State 变量。

创建自定义键盘视图

基本键盘布局搭建

首先,我们来创建一个简单的自定义键盘布局。我们可以使用 VStackHStack 来构建键盘的行和列结构。

struct CustomKeyboard: View {
    var body: some View {
        VStack {
            HStack {
                Button("1") {
                    // 处理点击事件
                }
               .frame(width: 50, height: 50)
                Button("2") {
                    // 处理点击事件
                }
               .frame(width: 50, height: 50)
                Button("3") {
                    // 处理点击事件
                }
               .frame(width: 50, height: 50)
            }
            HStack {
                Button("4") {
                    // 处理点击事件
                }
               .frame(width: 50, height: 50)
                Button("5") {
                    // 处理点击事件
                }
               .frame(width: 50, height: 50)
                Button("6") {
                    // 处理点击事件
                }
               .frame(width: 50, height: 50)
            }
        }
    }
}

在这个示例中,我们构建了一个简单的两行键盘布局,每行有三个按钮。每个按钮的点击事件目前只是占位,后续我们会填充实际的输入处理逻辑。

样式定制

为了让我们的自定义键盘看起来更美观,我们可以对按钮的样式进行定制。例如,添加背景颜色、圆角、字体等样式。

struct CustomKeyboard: View {
    var body: some View {
        VStack {
            HStack {
                Button("1") {
                    // 处理点击事件
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
                Button("2") {
                    // 处理点击事件
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
                Button("3") {
                    // 处理点击事件
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
            }
            HStack {
                Button("4") {
                    // 处理点击事件
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
                Button("5") {
                    // 处理点击事件
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
                Button("6") {
                    // 处理点击事件
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
            }
        }
    }
}

通过上述代码,我们为每个按钮添加了蓝色背景、白色文字和 10 个点的圆角,使键盘看起来更有质感。

自定义键盘与输入视图的交互

传递输入值

要实现自定义键盘与输入视图的交互,我们需要一种机制来传递用户在键盘上点击的字符或操作。一种常见的方法是通过绑定(Binding)。

假设我们有一个 ContentView 包含一个 TextField 和我们的 CustomKeyboard,我们可以这样实现输入值的传递:

struct ContentView: View {
    @State private var inputText = ""
    var body: some View {
        VStack {
            TextField("Enter text", text: $inputText)
            CustomKeyboard(text: $inputText)
        }
    }
}

struct CustomKeyboard: View {
    @Binding var text: String
    var body: some View {
        VStack {
            HStack {
                Button("1") {
                    text.append("1")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
                Button("2") {
                    text.append("2")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
                Button("3") {
                    text.append("3")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
            }
            HStack {
                Button("4") {
                    text.append("4")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
                Button("5") {
                    text.append("5")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
                Button("6") {
                    text.append("6")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
            }
        }
    }
}

在这个示例中,ContentView 中的 TextFieldCustomKeyboard 都绑定到了同一个 @State 变量 inputText。当用户点击 CustomKeyboard 上的按钮时,相应的字符会追加到 inputText 中,从而实时更新 TextField 的显示内容。

处理特殊操作

除了普通字符输入,我们的自定义键盘可能还需要处理一些特殊操作,比如删除、换行等。

对于删除操作,我们可以在键盘上添加一个删除按钮,并在点击时删除 text 中的最后一个字符。

struct CustomKeyboard: View {
    @Binding var text: String
    var body: some View {
        VStack {
            HStack {
                Button("1") {
                    text.append("1")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
                Button("2") {
                    text.append("2")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
                Button("3") {
                    text.append("3")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
            }
            HStack {
                Button("4") {
                    text.append("4")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
                Button("5") {
                    text.append("5")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
                Button("6") {
                    text.append("6")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
            }
            HStack {
                Button("Delete") {
                    if!text.isEmpty {
                        text.removeLast()
                    }
                }
               .frame(width: 100, height: 50)
               .background(Color.red)
               .foregroundColor(.white)
               .cornerRadius(10)
            }
        }
    }
}

在上述代码中,我们添加了一个 “Delete” 按钮,当点击该按钮时,如果 text 不为空,则删除最后一个字符。

高级自定义键盘功能

动态键盘布局

有时候,我们可能需要根据用户的输入状态或应用场景动态改变键盘布局。例如,在输入密码时,可能需要显示数字和字母混合的键盘,而在输入金额时,只需要数字和小数点键盘。

我们可以通过一个状态变量来控制键盘布局的切换。

struct ContentView: View {
    @State private var inputText = ""
    @State private var isPasswordInput = false
    var body: some View {
        VStack {
            if isPasswordInput {
                TextField("Enter password", text: $inputText, isSecureField: true)
            } else {
                TextField("Enter text", text: $inputText)
            }
            if isPasswordInput {
                PasswordKeyboard(text: $inputText)
            } else {
                BasicKeyboard(text: $inputText)
            }
            Button("Toggle Keyboard") {
                isPasswordInput.toggle()
            }
        }
    }
}

struct BasicKeyboard: View {
    @Binding var text: String
    var body: some View {
        VStack {
            HStack {
                Button("1") {
                    text.append("1")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
                Button("2") {
                    text.append("2")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
                Button("3") {
                    text.append("3")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
            }
        }
    }
}

struct PasswordKeyboard: View {
    @Binding var text: String
    var body: some View {
        VStack {
            HStack {
                Button("A") {
                    text.append("A")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
                Button("1") {
                    text.append("1")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
                Button("a") {
                    text.append("a")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
            }
        }
    }
}

在这个示例中,ContentView 中有一个 isPasswordInput 的状态变量,通过点击 “Toggle Keyboard” 按钮可以切换这个状态。根据这个状态,会显示不同的键盘布局,即 BasicKeyboardPasswordKeyboard

键盘动画与过渡效果

为了提升用户体验,我们可以为自定义键盘添加一些动画和过渡效果。例如,当键盘弹出或收起时,添加淡入淡出或滑动的动画。

struct ContentView: View {
    @State private var inputText = ""
    @State private var isKeyboardVisible = false
    var body: some View {
        VStack {
            TextField("Enter text", text: $inputText)
           .onTapGesture {
                isKeyboardVisible = true
            }
            if isKeyboardVisible {
                CustomKeyboard(text: $inputText)
               .transition(.slide)
               .animation(.easeInOut, value: isKeyboardVisible)
            }
        }
    }
}

struct CustomKeyboard: View {
    @Binding var text: String
    var body: some View {
        VStack {
            HStack {
                Button("1") {
                    text.append("1")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
                Button("2") {
                    text.append("2")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
                Button("3") {
                    text.append("3")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
            }
        }
    }
}

在上述代码中,isKeyboardVisible 控制自定义键盘的显示与隐藏。当 TextField 被点击时,isKeyboardVisible 设为 true,此时自定义键盘通过 .transition(.slide).animation(.easeInOut, value: isKeyboardVisible) 添加了滑动和淡入淡出的动画效果。

处理不同设备与屏幕尺寸

适配 iPhone 和 iPad

SwiftUI 的优势之一是其强大的响应式布局能力,这使得我们可以轻松地适配不同设备和屏幕尺寸。对于自定义键盘,我们可以利用 GeometryReader 来根据屏幕大小动态调整键盘按钮的大小和布局。

struct CustomKeyboard: View {
    @Binding var text: String
    var body: some View {
        GeometryReader { geometry in
            VStack {
                HStack {
                    Button("1") {
                        text.append("1")
                    }
                   .frame(width: geometry.size.width / 3, height: geometry.size.height / 4)
                   .background(Color.blue)
                   .foregroundColor(.white)
                   .cornerRadius(10)
                    Button("2") {
                        text.append("2")
                    }
                   .frame(width: geometry.size.width / 3, height: geometry.size.height / 4)
                   .background(Color.blue)
                   .foregroundColor(.white)
                   .cornerRadius(10)
                    Button("3") {
                        text.append("3")
                    }
                   .frame(width: geometry.size.width / 3, height: geometry.size.height / 4)
                   .background(Color.blue)
                   .foregroundColor(.white)
                   .cornerRadius(10)
                }
            }
        }
    }
}

在这个示例中,GeometryReader 获取到父视图的大小信息,然后根据屏幕宽度将按钮宽度设为屏幕宽度的三分之一,根据屏幕高度将按钮高度设为屏幕高度的四分之一。这样,无论在 iPhone 还是 iPad 上,键盘都能自适应屏幕尺寸。

横屏与竖屏适配

除了不同设备的适配,我们还需要考虑横屏和竖屏模式下的布局变化。SwiftUI 可以通过 orientation 环境变量来检测设备的方向,并相应地调整布局。

struct CustomKeyboard: View {
    @Binding var text: String
    var body: some View {
        let orientation = UIDevice.current.orientation
        VStack {
            if orientation.isLandscape {
                HStack {
                    Button("1") {
                        text.append("1")
                    }
                   .frame(width: 50, height: 50)
                   .background(Color.blue)
                   .foregroundColor(.white)
                   .cornerRadius(10)
                    Button("2") {
                        text.append("2")
                    }
                   .frame(width: 50, height: 50)
                   .background(Color.blue)
                   .foregroundColor(.white)
                   .cornerRadius(10)
                    Button("3") {
                        text.append("3")
                    }
                   .frame(width: 50, height: 50)
                   .background(Color.blue)
                   .foregroundColor(.white)
                   .cornerRadius(10)
                    Button("4") {
                        text.append("4")
                    }
                   .frame(width: 50, height: 50)
                   .background(Color.blue)
                   .foregroundColor(.white)
                   .cornerRadius(10)
                    Button("5") {
                        text.append("5")
                    }
                   .frame(width: 50, height: 50)
                   .background(Color.blue)
                   .foregroundColor(.white)
                   .cornerRadius(10)
                    Button("6") {
                        text.append("6")
                    }
                   .frame(width: 50, height: 50)
                   .background(Color.blue)
                   .foregroundColor(.white)
                   .cornerRadius(10)
                }
            } else {
                HStack {
                    Button("1") {
                        text.append("1")
                    }
                   .frame(width: 50, height: 50)
                   .background(Color.blue)
                   .foregroundColor(.white)
                   .cornerRadius(10)
                    Button("2") {
                        text.append("2")
                    }
                   .frame(width: 50, height: 50)
                   .background(Color.blue)
                   .foregroundColor(.white)
                   .cornerRadius(10)
                    Button("3") {
                        text.append("3")
                    }
                   .frame(width: 50, height: 50)
                   .background(Color.blue)
                   .foregroundColor(.white)
                   .cornerRadius(10)
                }
                HStack {
                    Button("4") {
                        text.append("4")
                    }
                   .frame(width: 50, height: 50)
                   .background(Color.blue)
                   .foregroundColor(.white)
                   .cornerRadius(10)
                    Button("5") {
                        text.append("5")
                    }
                   .frame(width: 50, height: 50)
                   .background(Color.blue)
                   .foregroundColor(.white)
                   .cornerRadius(10)
                    Button("6") {
                        text.append("6")
                    }
                   .frame(width: 50, height: 50)
                   .background(Color.blue)
                   .foregroundColor(.white)
                   .cornerRadius(10)
                }
            }
        }
    }
}

在上述代码中,通过检测设备的方向,如果是横屏模式,则将按钮布局为一行六个;如果是竖屏模式,则布局为两行,每行三个按钮。这样可以充分利用不同方向下的屏幕空间,提供更好的用户体验。

自定义键盘的性能优化

减少视图重绘

在自定义键盘开发中,频繁的视图重绘可能会导致性能问题。为了减少视图重绘,我们可以尽量使用 @Binding 来传递数据,而不是使用 @State 在子视图中创建新的状态。

例如,避免在 CustomKeyboard 中使用 @State 来管理输入文本:

// 不好的做法
struct CustomKeyboard: View {
    @State private var localText = ""
    var body: some View {
        VStack {
            HStack {
                Button("1") {
                    localText.append("1")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
            }
        }
    }
}

而应该使用 @Binding 来绑定外部传递的文本:

// 好的做法
struct CustomKeyboard: View {
    @Binding var text: String
    var body: some View {
        VStack {
            HStack {
                Button("1") {
                    text.append("1")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
            }
        }
    }
}

这样可以确保只有在实际输入发生变化时才会触发视图更新,而不是因为子视图内部状态的变化而不必要地重绘。

内存管理

当自定义键盘包含大量的视图或数据时,合理的内存管理至关重要。对于不再使用的视图,SwiftUI 会自动进行内存回收,但我们也可以通过一些方式来优化内存使用。

例如,如果我们有一些临时数据用于键盘的操作,在操作完成后及时释放这些数据。假设我们有一个数组用于存储最近输入的字符,在不需要时可以将其置为 nil

struct CustomKeyboard: View {
    @Binding var text: String
    var body: some View {
        var recentChars: [Character]?
        VStack {
            HStack {
                Button("1") {
                    text.append("1")
                    if recentChars == nil {
                        recentChars = []
                    }
                    recentChars?.append("1")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
            }
            // 当不再需要 recentChars 时
            Button("Clear Recent") {
                recentChars = nil
            }
        }
    }
}

通过这种方式,可以避免不必要的内存占用,提高应用的整体性能。

与系统键盘的集成与切换

混合使用自定义与系统键盘

在某些场景下,我们可能既需要使用系统键盘的部分功能,又需要自定义键盘的特殊输入。我们可以通过检测输入类型来决定使用哪种键盘。

例如,对于普通文本输入,我们可以使用系统键盘;而对于特定格式的输入,如日期格式,我们可以切换到自定义键盘。

struct ContentView: View {
    @State private var inputText = ""
    @State private var isDateInput = false
    var body: some View {
        VStack {
            if isDateInput {
                TextField("Enter date", text: $inputText)
                DateKeyboard(text: $inputText)
            } else {
                TextField("Enter text", text: $inputText)
            }
            Button("Toggle Keyboard") {
                isDateInput.toggle()
            }
        }
    }
}

struct DateKeyboard: View {
    @Binding var text: String
    var body: some View {
        VStack {
            HStack {
                Button("1") {
                    text.append("1")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
                Button("/") {
                    text.append("/")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
            }
        }
    }
}

在这个示例中,通过点击 “Toggle Keyboard” 按钮可以在系统键盘和自定义的 DateKeyboard 之间切换,以满足不同的输入需求。

优雅的键盘切换动画

为了提供更好的用户体验,在系统键盘和自定义键盘之间切换时,我们可以添加一些动画效果。

struct ContentView: View {
    @State private var inputText = ""
    @State private var isDateInput = false
    var body: some View {
        VStack {
            if isDateInput {
                TextField("Enter date", text: $inputText)
                DateKeyboard(text: $inputText)
               .transition(.scale)
               .animation(.easeInOut, value: isDateInput)
            } else {
                TextField("Enter text", text: $inputText)
            }
            Button("Toggle Keyboard") {
                isDateInput.toggle()
            }
        }
    }
}

struct DateKeyboard: View {
    @Binding var text: String
    var body: some View {
        VStack {
            HStack {
                Button("1") {
                    text.append("1")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
                Button("/") {
                    text.append("/")
                }
               .frame(width: 50, height: 50)
               .background(Color.blue)
               .foregroundColor(.white)
               .cornerRadius(10)
            }
        }
    }
}

在上述代码中,当切换到自定义的 DateKeyboard 时,通过 .transition(.scale).animation(.easeInOut, value: isDateInput) 添加了缩放和淡入淡出的动画效果,使键盘切换更加平滑和自然。

通过以上内容,我们全面地了解了 SwiftUI 中自定义键盘与输入视图的开发方法,包括基础概念、视图创建、交互实现、高级功能、设备适配、性能优化以及与系统键盘的集成等方面。希望这些知识和代码示例能够帮助开发者在 SwiftUI 项目中打造出更加个性化、高效且用户体验良好的输入解决方案。