mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-19 18:33:00 +08:00
541 lines
20 KiB
Swift
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
|
|
}
|
|
}
|