mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-22 04:23:06 +08:00
[feat] add iOS system keyboard input and improve computer keyboard
This commit is contained in:
@@ -0,0 +1,540 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// UIKit owns composition, candidate selection, keyboard switching and dictation.
|
||||
/// Confirmed text is mirrored to the remote app; preedit stays in the IME.
|
||||
struct RemoteSystemKeyboard: View {
|
||||
@ObservedObject var session: RemoteSessionModel
|
||||
@StateObject private var keyboard = RemoteKeyboardController()
|
||||
var inputContext: RemoteKeyboardInputContext? = nil
|
||||
var geometry: RemoteKeyboardGeometry? = nil
|
||||
var animatePresentation = true
|
||||
var didPresent: () -> Void = {}
|
||||
let switchKeyboard: () -> Void
|
||||
let dismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
RemoteKeyboardHost(
|
||||
controller: keyboard,
|
||||
edit: session.sendSystemTextEdit,
|
||||
inputContext: inputContext,
|
||||
geometry: geometry,
|
||||
animatePresentation: animatePresentation,
|
||||
didPresent: didPresent,
|
||||
key: session.sendKeyStroke,
|
||||
status: session.keyboardInputStatus,
|
||||
switchKeyboard: switchKeyboard,
|
||||
dismiss: dismiss
|
||||
)
|
||||
// The native host tracks the keyboard itself, including its animation.
|
||||
// SwiftUI must not also resize it to the keyboard's safe area.
|
||||
.ignoresSafeArea()
|
||||
.onDisappear { keyboard.stop() }
|
||||
}
|
||||
}
|
||||
|
||||
private struct RemoteKeyboardHost: UIViewControllerRepresentable {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let controller: RemoteKeyboardController
|
||||
let edit: (Int, String) -> Bool
|
||||
let inputContext: RemoteKeyboardInputContext?
|
||||
let geometry: RemoteKeyboardGeometry?
|
||||
let animatePresentation: Bool
|
||||
let didPresent: () -> Void
|
||||
let key: (UInt) -> Void
|
||||
let status: String
|
||||
let switchKeyboard: () -> Void
|
||||
let dismiss: () -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> RemoteKeyboardController {
|
||||
update(controller)
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ controller: RemoteKeyboardController,
|
||||
context: Context) {
|
||||
update(controller)
|
||||
}
|
||||
|
||||
private func update(_ controller: RemoteKeyboardController) {
|
||||
controller.editor.applyEdit = edit
|
||||
inputContext?.editor = controller.editor
|
||||
controller.geometry = geometry
|
||||
controller.animatePresentation = animatePresentation
|
||||
controller.didPresent = didPresent
|
||||
controller.editor.key = key
|
||||
controller.inputBar.switchKeyboard = { [weak controller] in
|
||||
controller?.rememberKeyboardHeight()
|
||||
// Let SwiftUI install the replacement panel before teardown hides
|
||||
// the native keyboard, avoiding an empty frame between the two.
|
||||
controller?.animateDismissal = false
|
||||
switchKeyboard()
|
||||
}
|
||||
controller.inputBar.dismiss = { [weak controller] in
|
||||
controller?.stop()
|
||||
dismiss()
|
||||
}
|
||||
controller.overrideUserInterfaceStyle = colorScheme == .dark ? .dark : .light
|
||||
controller.inputBar.setStatus(status)
|
||||
}
|
||||
|
||||
static func dismantleUIViewController(_ controller: RemoteKeyboardController,
|
||||
coordinator: ()) {
|
||||
controller.stop()
|
||||
}
|
||||
}
|
||||
|
||||
final class RemoteKeyboardController: UIViewController, ObservableObject {
|
||||
let editor: RemoteTextInputView
|
||||
let inputBar: RemoteKeyboardInputBar
|
||||
var geometry: RemoteKeyboardGeometry?
|
||||
var animatePresentation = true
|
||||
var animateDismissal = true
|
||||
var didPresent: (() -> Void)?
|
||||
private var acceptingInput = true
|
||||
private var animationsEnabledBeforePresentation: Bool?
|
||||
|
||||
init() {
|
||||
let editor = RemoteTextInputView()
|
||||
editor.automaticallyFocus = false
|
||||
self.editor = editor
|
||||
self.inputBar = RemoteKeyboardInputBar()
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func loadView() {
|
||||
let host = RemoteKeyboardHostView()
|
||||
host.willDetach = { [weak self] in self?.stop() }
|
||||
view = host
|
||||
view.backgroundColor = .clear
|
||||
host.inputBar = inputBar
|
||||
inputBar.translatesAutoresizingMaskIntoConstraints = false
|
||||
host.addSubview(inputBar)
|
||||
// Keep a real UITextField for IME and dictation context, but never
|
||||
// display a second copy of the remote text or a local caret.
|
||||
editor.isAccessibilityElement = false
|
||||
editor.translatesAutoresizingMaskIntoConstraints = false
|
||||
host.addSubview(editor)
|
||||
|
||||
// Keep one continuous background behind the row and the system
|
||||
// keyboard's rounded corners, without reserving an input accessory slot.
|
||||
let backdrop = UIView()
|
||||
backdrop.backgroundColor = RemoteKeyboardInputBar.keyboardBackground
|
||||
backdrop.isUserInteractionEnabled = false
|
||||
backdrop.translatesAutoresizingMaskIntoConstraints = false
|
||||
host.insertSubview(backdrop, at: 0)
|
||||
NSLayoutConstraint.activate([
|
||||
editor.leadingAnchor.constraint(equalTo: inputBar.leadingAnchor),
|
||||
editor.topAnchor.constraint(equalTo: inputBar.topAnchor),
|
||||
editor.widthAnchor.constraint(equalToConstant: 1),
|
||||
editor.heightAnchor.constraint(equalToConstant: 1),
|
||||
inputBar.leadingAnchor.constraint(equalTo: host.leadingAnchor),
|
||||
inputBar.trailingAnchor.constraint(equalTo: host.trailingAnchor),
|
||||
inputBar.bottomAnchor.constraint(equalTo: host.keyboardLayoutGuide.topAnchor),
|
||||
backdrop.topAnchor.constraint(equalTo: inputBar.topAnchor),
|
||||
backdrop.leadingAnchor.constraint(equalTo: host.leadingAnchor),
|
||||
backdrop.trailingAnchor.constraint(equalTo: host.trailingAnchor),
|
||||
backdrop.bottomAnchor.constraint(equalTo: host.bottomAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
guard acceptingInput, !editor.isFirstResponder else { return }
|
||||
if animatePresentation {
|
||||
editor.becomeFirstResponder()
|
||||
} else {
|
||||
// UIKit may start showing the keyboard after becomeFirstResponder
|
||||
// returns. Keep this short suppression alive until it is shown,
|
||||
// rather than ending it at the end of a synchronous animation block.
|
||||
animationsEnabledBeforePresentation = UIView.areAnimationsEnabled
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(finishKeyboardPresentation),
|
||||
name: UIResponder.keyboardDidShowNotification, object: nil)
|
||||
UIView.setAnimationsEnabled(false)
|
||||
if !editor.becomeFirstResponder() {
|
||||
finishKeyboardPresentation()
|
||||
}
|
||||
view.layoutIfNeeded()
|
||||
// A hardware keyboard or an interrupted focus request may produce
|
||||
// no did-show notification. Always restore the previous state.
|
||||
// Retain the controller until cleanup even if its host disappears.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.finishKeyboardPresentation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func finishKeyboardPresentation() {
|
||||
guard let enabled = animationsEnabledBeforePresentation else { return }
|
||||
animationsEnabledBeforePresentation = nil
|
||||
NotificationCenter.default.removeObserver(
|
||||
self, name: UIResponder.keyboardDidShowNotification, object: nil)
|
||||
UIView.setAnimationsEnabled(enabled)
|
||||
if acceptingInput {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.acceptingInput else { return }
|
||||
self.didPresent?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
rememberKeyboardHeight()
|
||||
}
|
||||
|
||||
func rememberKeyboardHeight() {
|
||||
guard acceptingInput, editor.isFirstResponder else { return }
|
||||
let keyboard = view.keyboardLayoutGuide.layoutFrame
|
||||
// Ignore hidden, floating and split keyboards: they do not define a
|
||||
// full-width docked panel that the computer keyboard can replace.
|
||||
guard keyboard.height > 100,
|
||||
keyboard.width >= view.bounds.width - 1 else { return }
|
||||
geometry?.remember(height: view.bounds.maxY - keyboard.minY +
|
||||
RemoteKeyboardInputBar.barHeight,
|
||||
size: view.bounds.size)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard acceptingInput else { return }
|
||||
acceptingInput = false
|
||||
finishKeyboardPresentation()
|
||||
editor.stop()
|
||||
inputBar.switchKeyboard = nil
|
||||
inputBar.dismiss = nil
|
||||
let dismiss = {
|
||||
self.editor.resignFirstResponder()
|
||||
self.editor.removeFromSuperview()
|
||||
}
|
||||
if animateDismissal {
|
||||
dismiss()
|
||||
} else {
|
||||
UIView.performWithoutAnimation(dismiss)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retain the last native height for each window width and orientation, even
|
||||
/// after the text field resigns first responder during a keyboard switch.
|
||||
final class RemoteKeyboardGeometry: ObservableObject {
|
||||
private var heights: [String: CGFloat] = [:]
|
||||
|
||||
private func key(for size: CGSize) -> String {
|
||||
"\(Int(size.width.rounded()))-\(size.width > size.height)"
|
||||
}
|
||||
|
||||
func remember(height: CGFloat, size: CGSize) {
|
||||
heights[key(for: size)] = height
|
||||
}
|
||||
|
||||
func panelHeight(in size: CGSize, bottomInset: CGFloat) -> CGFloat {
|
||||
// A rotation may reach a size at which the native keyboard has not yet
|
||||
// appeared. The compact iPhone keybed is 161 points; using the full
|
||||
// 216-point keybed here makes only the first landscape switch jump.
|
||||
// Safe-area and toolbar heights are added separately below.
|
||||
let fallback: CGFloat = size.width > size.height ? 161 : 300
|
||||
let height = heights[key(for: size)] ??
|
||||
(fallback + bottomInset + RemoteKeyboardInputBar.barHeight)
|
||||
return min(height, size.height)
|
||||
}
|
||||
}
|
||||
|
||||
private final class RemoteKeyboardHostView: UIView {
|
||||
var willDetach: (() -> Void)?
|
||||
weak var inputBar: UIView?
|
||||
|
||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
guard let inputBar else { return nil }
|
||||
// Video gestures outside the input row must reach the remote viewport.
|
||||
return inputBar.hitTest(convert(point, to: inputBar), with: event)
|
||||
}
|
||||
|
||||
override func willMove(toSuperview newSuperview: UIView?) {
|
||||
if newSuperview == nil { willDetach?() }
|
||||
super.willMove(toSuperview: newSuperview)
|
||||
}
|
||||
}
|
||||
|
||||
/// Use the same UIKit symbol layout in both the native and SwiftUI toolbars.
|
||||
/// Matching font sizes alone still gives their symbols different baselines.
|
||||
struct RemoteKeyboardDismissButton: UIViewRepresentable {
|
||||
static let sideLength: CGFloat = 44
|
||||
let accessibilityLabel: String
|
||||
let action: () -> Void
|
||||
|
||||
static func makeButton() -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
button.tintColor = .label
|
||||
button.setImage(UIImage(systemName: "keyboard.chevron.compact.down"), for: .normal)
|
||||
button.setPreferredSymbolConfiguration(
|
||||
UIImage.SymbolConfiguration(pointSize: 20, weight: .regular, scale: .medium),
|
||||
forImageIn: .normal)
|
||||
button.contentHorizontalAlignment = .center
|
||||
button.contentVerticalAlignment = .center
|
||||
return button
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(action: action) }
|
||||
|
||||
func makeUIView(context: Context) -> UIButton {
|
||||
let button = Self.makeButton()
|
||||
button.addTarget(context.coordinator, action: #selector(Coordinator.pressed),
|
||||
for: .touchUpInside)
|
||||
return button
|
||||
}
|
||||
|
||||
func updateUIView(_ button: UIButton, context: Context) {
|
||||
button.accessibilityLabel = accessibilityLabel
|
||||
context.coordinator.action = action
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject {
|
||||
var action: () -> Void
|
||||
|
||||
init(action: @escaping () -> Void) { self.action = action }
|
||||
|
||||
@objc func pressed() { action() }
|
||||
}
|
||||
}
|
||||
|
||||
final class RemoteKeyboardInputBar: UIView {
|
||||
static let barHeight: CGFloat = 44
|
||||
static let keyboardBackground = UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark ? .secondarySystemBackground : .systemGray4
|
||||
}
|
||||
var switchKeyboard: (() -> Void)?
|
||||
var dismiss: (() -> Void)?
|
||||
private let statusLabel = UILabel()
|
||||
private let row = UIStackView()
|
||||
private var status = ""
|
||||
private var statusHeight: CGFloat = 0
|
||||
private var heightConstraint: NSLayoutConstraint!
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
backgroundColor = .clear
|
||||
tintColor = .label
|
||||
heightConstraint = heightAnchor.constraint(equalToConstant: Self.barHeight)
|
||||
heightConstraint.isActive = true
|
||||
|
||||
let computerButton = UIButton(type: .system)
|
||||
computerButton.setTitle("电脑键盘", for: .normal)
|
||||
computerButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium)
|
||||
computerButton.accessibilityLabel = "切换到电脑键盘"
|
||||
computerButton.addTarget(self, action: #selector(switchToComputer), for: .touchUpInside)
|
||||
|
||||
let dismissButton = RemoteKeyboardDismissButton.makeButton()
|
||||
dismissButton.accessibilityLabel = "收起系统键盘"
|
||||
dismissButton.addTarget(self, action: #selector(hideKeyboard), for: .touchUpInside)
|
||||
|
||||
row.axis = .horizontal
|
||||
row.alignment = .fill
|
||||
row.spacing = 4
|
||||
for item in [computerButton, UIView(), dismissButton] { row.addArrangedSubview(item) }
|
||||
row.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(row)
|
||||
NSLayoutConstraint.activate([
|
||||
computerButton.widthAnchor.constraint(equalToConstant: 80),
|
||||
dismissButton.widthAnchor.constraint(equalToConstant: RemoteKeyboardDismissButton.sideLength),
|
||||
row.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 4),
|
||||
row.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -4),
|
||||
row.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
row.heightAnchor.constraint(equalToConstant: Self.barHeight)
|
||||
])
|
||||
|
||||
statusLabel.font = .systemFont(ofSize: 12)
|
||||
statusLabel.textColor = .secondaryLabel
|
||||
statusLabel.numberOfLines = 0
|
||||
statusLabel.isHidden = true
|
||||
addSubview(statusLabel)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
CGSize(width: UIView.noIntrinsicMetric, height: Self.barHeight + statusHeight)
|
||||
}
|
||||
|
||||
func setStatus(_ value: String) {
|
||||
guard status != value else { return }
|
||||
status = value
|
||||
statusLabel.text = value
|
||||
statusLabel.isHidden = value.isEmpty
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
let inset = max(8, safeAreaInsets.left)
|
||||
let width = max(1, bounds.width - inset - max(8, safeAreaInsets.right))
|
||||
let height = status.isEmpty ? 0 : ceil(statusLabel.sizeThatFits(
|
||||
CGSize(width: width, height: .greatestFiniteMagnitude)).height) + 4
|
||||
statusLabel.frame = CGRect(x: inset, y: 2, width: width, height: max(0, height - 4))
|
||||
if statusHeight != height {
|
||||
statusHeight = height
|
||||
heightConstraint.constant = Self.barHeight + height
|
||||
invalidateIntrinsicContentSize()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func switchToComputer() { switchKeyboard?() }
|
||||
@objc private func hideKeyboard() { dismiss?() }
|
||||
}
|
||||
|
||||
/// Resets the local IME context synchronously before a remote click can move
|
||||
/// the insertion point. Nothing typed at the old location may be rewritten there.
|
||||
final class RemoteKeyboardInputContext: ObservableObject {
|
||||
weak var editor: RemoteTextInputView?
|
||||
|
||||
func remoteFocusWillChange() {
|
||||
editor?.finishInputContext()
|
||||
}
|
||||
}
|
||||
|
||||
final class RemoteTextInputView: UITextField, UITextFieldDelegate {
|
||||
var applyEdit: ((Int, String) -> Bool)?
|
||||
var key: ((UInt) -> Void)?
|
||||
private(set) var acceptingInput = true
|
||||
var automaticallyFocus = true
|
||||
private var sentText = ""
|
||||
private var changeScheduled = false
|
||||
private var resetting = false
|
||||
private var generation: UInt64 = 0
|
||||
|
||||
override var canBecomeFirstResponder: Bool { acceptingInput && super.canBecomeFirstResponder }
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
delegate = self
|
||||
textColor = .clear
|
||||
tintColor = .clear
|
||||
backgroundColor = .clear
|
||||
borderStyle = .none
|
||||
keyboardType = .default
|
||||
returnKeyType = .default
|
||||
autocorrectionType = .default
|
||||
autocapitalizationType = .none
|
||||
spellCheckingType = .default
|
||||
smartQuotesType = .no
|
||||
smartDashesType = .no
|
||||
smartInsertDeleteType = .no
|
||||
inputAssistantItem.leadingBarButtonGroups = []
|
||||
inputAssistantItem.trailingBarButtonGroups = []
|
||||
addTarget(self, action: #selector(scheduleTextChange), for: .editingChanged)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
if window != nil && acceptingInput && automaticallyFocus { becomeFirstResponder() }
|
||||
}
|
||||
|
||||
override func caretRect(for position: UITextPosition) -> CGRect { .zero }
|
||||
override func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { [] }
|
||||
|
||||
func textFieldDidChangeSelection(_ textField: UITextField) { scheduleTextChange() }
|
||||
|
||||
override func insertText(_ text: String) {
|
||||
guard acceptingInput else { return }
|
||||
if (text == "\n" || text == "\r") && markedTextRange == nil {
|
||||
_ = textFieldShouldReturn(self)
|
||||
return
|
||||
}
|
||||
super.insertText(text)
|
||||
scheduleTextChange()
|
||||
}
|
||||
|
||||
override func replace(_ range: UITextRange, withText text: String) {
|
||||
guard acceptingInput else { return }
|
||||
super.replace(range, withText: text)
|
||||
scheduleTextChange()
|
||||
}
|
||||
|
||||
override func setMarkedText(_ markedText: String?, selectedRange: NSRange) {
|
||||
guard acceptingInput else { return }
|
||||
super.setMarkedText(markedText, selectedRange: selectedRange)
|
||||
scheduleTextChange()
|
||||
}
|
||||
|
||||
override func unmarkText() {
|
||||
super.unmarkText()
|
||||
scheduleTextChange()
|
||||
}
|
||||
|
||||
override func deleteBackward() {
|
||||
guard acceptingInput else { return }
|
||||
if !hasText && markedTextRange == nil {
|
||||
// Flush any pending deletion before deleting beyond local context.
|
||||
if synchronizeText() { key?(0x08) }
|
||||
} else {
|
||||
super.deleteBackward()
|
||||
scheduleTextChange()
|
||||
}
|
||||
}
|
||||
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
guard acceptingInput, markedTextRange == nil else { return false }
|
||||
if synchronizeText() {
|
||||
key?(0x0d)
|
||||
resetContext()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@objc private func scheduleTextChange() {
|
||||
guard acceptingInput, !resetting, !changeScheduled else { return }
|
||||
changeScheduled = true
|
||||
let currentGeneration = generation
|
||||
// UIKit may replace text and clear markedTextRange in separate calls.
|
||||
// Wait one run-loop turn so only the completed IME transaction is sent.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.generation == currentGeneration else { return }
|
||||
self.changeScheduled = false
|
||||
_ = self.synchronizeText()
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func synchronizeText() -> Bool {
|
||||
guard acceptingInput, !resetting, markedTextRange == nil else { return false }
|
||||
let current = text ?? ""
|
||||
guard current != sentText else { return true }
|
||||
let prefixCount = zip(sentText, current).prefix(while: { $0 == $1 }).count
|
||||
let deleteCount = sentText.count - prefixCount
|
||||
let insertion = String(current.dropFirst(prefixCount))
|
||||
guard applyEdit?(deleteCount, insertion) == true else {
|
||||
// Do not keep invisible rejected input to replay on a later key.
|
||||
resetContext()
|
||||
return false
|
||||
}
|
||||
sentText = current
|
||||
return true
|
||||
}
|
||||
|
||||
func finishInputContext() {
|
||||
guard acceptingInput else { return }
|
||||
_ = synchronizeText()
|
||||
resetContext()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
acceptingInput = false
|
||||
applyEdit = nil
|
||||
key = nil
|
||||
resetContext()
|
||||
}
|
||||
|
||||
private func resetContext() {
|
||||
generation &+= 1
|
||||
changeScheduled = false
|
||||
resetting = true
|
||||
super.unmarkText()
|
||||
text = ""
|
||||
sentText = ""
|
||||
resetting = false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user