Files
crossdesk/apps/ios/CrossDeskMobile/Input/RemoteSystemKeyboard.swift
T

541 lines
20 KiB
Swift

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
}
}