[feat] add compact mobile virtual mouse controls

This commit is contained in:
dijunkun
2026-10-09 14:26:36 +08:00
parent 41aed0984f
commit d3ecbe2cc9
12 changed files with 961 additions and 205 deletions
@@ -7,6 +7,8 @@
objects = {
/* Begin PBXBuildFile section */
10000000000000000000001D /* RemoteMouseInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001D /* RemoteMouseInput.swift */; };
10000000000000000000001E /* VirtualMouseBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001E /* VirtualMouseBar.swift */; };
100000000000000000000018 /* RecentConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000018 /* RecentConnection.swift */; };
100000000000000000000016 /* RemoteVideoSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000016 /* RemoteVideoSettings.swift */; };
100000000000000000000017 /* RemoteNetworkStatistics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000017 /* RemoteNetworkStatistics.swift */; };
@@ -34,6 +36,8 @@
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
20000000000000000000001D /* RemoteMouseInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteMouseInput.swift; sourceTree = "<group>"; };
20000000000000000000001E /* VirtualMouseBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualMouseBar.swift; sourceTree = "<group>"; };
200000000000000000000018 /* RecentConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentConnection.swift; sourceTree = "<group>"; };
200000000000000000000016 /* RemoteVideoSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteVideoSettings.swift; sourceTree = "<group>"; };
200000000000000000000017 /* RemoteNetworkStatistics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteNetworkStatistics.swift; sourceTree = "<group>"; };
@@ -149,6 +153,8 @@
isa = PBXGroup;
children = (
20000000000000000000000A /* RemoteTouchInputView.swift */,
20000000000000000000001D /* RemoteMouseInput.swift */,
20000000000000000000001E /* VirtualMouseBar.swift */,
);
path = Input;
sourceTree = "<group>";
@@ -289,6 +295,8 @@
100000000000000000000005 /* CrossDeskRTCBridge.mm in Sources */,
100000000000000000000006 /* NativeMetalVideoView.swift in Sources */,
100000000000000000000007 /* RemoteTouchInputView.swift in Sources */,
10000000000000000000001D /* RemoteMouseInput.swift in Sources */,
10000000000000000000001E /* VirtualMouseBar.swift in Sources */,
100000000000000000000012 /* PrivacyPreferences.swift in Sources */,
100000000000000000000013 /* ThumbnailFileStore.swift in Sources */,
100000000000000000000014 /* PrivacyViews.swift in Sources */,
@@ -0,0 +1,59 @@
import Foundation
/// Shared by the video surface and the virtual buttons. Coordinates always
/// refer to the remote cursor, never to the floating controls' screen location.
final class RemoteMouseInput {
var send: ((Float, Float, Int, Int) -> Void)?
var didCancel: (() -> Void)?
var viewportSize = CGSize(width: 0, height: 0)
private(set) var x: Float = 0.5
private(set) var y: Float = 0.5
private(set) var revision: UInt64 = 0
private var gestureLeft = false
private var virtualLeft = false
private var virtualRight = false
var hasVirtualButton: Bool { virtualLeft || virtualRight }
func position(x: Float, y: Float) {
self.x = min(max(x, 0), 1)
self.y = min(max(y, 0), 1)
}
func drag(dx: Float, dy: Float) {
guard hasVirtualButton, viewportSize.width > 0, viewportSize.height > 0 else { return }
position(x: x + dx / Float(viewportSize.width) * 1.35,
y: y + dy / Float(viewportSize.height) * 1.35)
emit(0)
}
func setGestureLeft(_ down: Bool) {
guard !(down && hasVirtualButton), gestureLeft != down else { return }
gestureLeft = down
emit(down ? 1 : 2)
}
func setVirtualButton(left: Bool, down: Bool) {
guard (left ? virtualLeft : virtualRight) != down else { return }
if down {
setGestureLeft(false)
revision &+= 1
}
if left { virtualLeft = down } else { virtualRight = down }
emit(left ? (down ? 1 : 2) : (down ? 3 : 4))
}
func scroll(_ direction: Int) {
guard direction != 0 else { return }
emit(7, wheel: direction > 0 ? 1 : -1)
}
func releaseAll() {
setGestureLeft(false)
setVirtualButton(left: true, down: false)
setVirtualButton(left: false, down: false)
revision &+= 1
didCancel?()
}
private func emit(_ flag: Int, wheel: Int = 0) { send?(x, y, flag, wheel) }
}
@@ -55,6 +55,7 @@ enum RemoteVideoGeometry {
}
struct RemoteTouchInputView: UIViewRepresentable {
let mouseInput: RemoteMouseInput
let videoSize: CGSize
let controlMode: MouseControlMode
let remoteCursorPosition: CGPoint?
@@ -73,6 +74,7 @@ struct RemoteTouchInputView: UIViewRepresentable {
}
func updateUIView(_ view: RemoteTouchSurface, context: Context) {
view.mouseInput = mouseInput
view.videoSize = videoSize
view.controlMode = controlMode
view.setViewport(scale: viewportScale, offset: viewportOffset)
@@ -88,6 +90,8 @@ struct RemoteTouchInputView: UIViewRepresentable {
}
final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
var mouseInput: RemoteMouseInput?
private var gestureRevisions: [ObjectIdentifier: UInt64] = [:]
var videoSize = CGSize.zero {
didSet { setNeedsLayout() }
}
@@ -108,7 +112,14 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
var onScroll: ((Float, Float, Int, Int) -> Void)?
var onViewportChanged: ((CGFloat, CGSize) -> Void)?
private var relativeCursorPoint: (Float, Float) = (0.5, 0.5)
private var fallbackCursorPoint: (Float, Float) = (0.5, 0.5)
private var relativeCursorPoint: (Float, Float) {
get { mouseInput.map { ($0.x, $0.y) } ?? fallbackCursorPoint }
set {
fallbackCursorPoint = newValue
mouseInput?.position(x: newValue.0, y: newValue.1)
}
}
private var lastRemoteCursorPoint: (Float, Float)?
private var lastRemoteCursorRevision: UInt64?
private var relativePanActive = false
@@ -128,10 +139,12 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
isMultipleTouchEnabled = true
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
tap.delegate = self
tap.numberOfTouchesRequired = 1
addGestureRecognizer(tap)
let rightTap = UITapGestureRecognizer(target: self, action: #selector(handleRightTap(_:)))
rightTap.delegate = self
rightTap.numberOfTouchesRequired = 2
tap.require(toFail: rightTap)
addGestureRecognizer(rightTap)
@@ -174,6 +187,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
override func layoutSubviews() {
super.layoutSubviews()
mouseInput?.viewportSize = renderRect()?.size ?? .zero
let constrained = constrainedViewportOffset(viewportOffset,
scale: viewportScale)
guard constrained != viewportOffset else { return }
@@ -185,6 +199,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
let nextScale = min(max(scale, 1), maximumViewportScale)
viewportScale = nextScale
viewportOffset = constrainedViewportOffset(offset, scale: nextScale)
mouseInput?.viewportSize = renderRect()?.size ?? .zero
}
func synchronizeRemoteCursor(_ position: CGPoint?, revision: UInt64) {
@@ -203,12 +218,39 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
)
lastRemoteCursorPoint = point
guard controlMode == .relative,
mouseInput?.hasVirtualButton != true,
!relativePanActive,
heldDragPoint == nil,
hoverLastLocation == nil else { return }
relativeCursorPoint = point
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldReceive touch: UITouch) -> Bool {
if gestureRecognizer is UITapGestureRecognizer || gestureRecognizer is UILongPressGestureRecognizer {
guard mouseInput?.hasVirtualButton != true else { return false }
gestureRevisions[ObjectIdentifier(gestureRecognizer)] = mouseInput?.revision ?? 0
}
return true
}
private func allowsPointerGesture(_ recognizer: UIGestureRecognizer) -> Bool {
mouseInput?.hasVirtualButton != true &&
gestureRevisions[ObjectIdentifier(recognizer)] == (mouseInput?.revision ?? 0)
}
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer is UITapGestureRecognizer || gestureRecognizer is UILongPressGestureRecognizer {
// Decline recognition before a stale long press can cancel the pan
// used by the other finger to move a held virtual mouse button.
return allowsPointerGesture(gestureRecognizer)
}
if gestureRecognizer is UIPinchGestureRecognizer {
return mouseInput?.hasVirtualButton != true
}
return true
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
let recognizers = [gestureRecognizer, otherGestureRecognizer]
@@ -223,6 +265,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
}
@objc private func handleTap(_ recognizer: UITapGestureRecognizer) {
guard allowsPointerGesture(recognizer) else { return }
let location = recognizer.location(in: self)
guard recognizer.state == .ended,
let point = pointerPoint(for: location) else { return }
@@ -240,6 +283,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
}
@objc private func handleRightTap(_ recognizer: UITapGestureRecognizer) {
guard allowsPointerGesture(recognizer) else { return }
guard recognizer.state == .ended,
let point = pointerPoint(for: recognizer.location(in: self)) else { return }
onMove?(point.0, point.1)
@@ -262,6 +306,12 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
}
@objc private func handleHeldDrag(_ recognizer: UILongPressGestureRecognizer) {
guard allowsPointerGesture(recognizer) else {
// A virtual button took ownership while the surface gesture was in flight.
heldDragPoint = nil
heldDragLastLocation = nil
return
}
if controlMode == .relative {
handleRelativeHeldDrag(recognizer)
return
@@ -289,6 +339,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
}
@objc private func handlePinch(_ recognizer: UIPinchGestureRecognizer) {
guard mouseInput?.hasVirtualButton != true else { pinchActive = false; return }
let focus = recognizer.location(in: self)
switch recognizer.state {
case .began:
@@ -327,6 +378,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
}
@objc private func handleWheel(_ recognizer: UIPanGestureRecognizer) {
guard mouseInput?.hasVirtualButton != true else { viewportPanActive = false; return }
if viewportScale > 1.001 || pinchActive {
switch recognizer.state {
case .began:
@@ -0,0 +1,209 @@
import SwiftUI
import UIKit
struct VirtualMouseBar: UIViewRepresentable {
let input: RemoteMouseInput
func makeUIView(context: Context) -> VirtualMouseBarView {
VirtualMouseBarView(input: input)
}
func updateUIView(_ view: VirtualMouseBarView, context: Context) {}
static func dismantleUIView(_ view: VirtualMouseBarView, coordinator: ()) {
view.input.releaseAll()
}
}
final class VirtualMouseBarView: UIView {
let input: RemoteMouseInput
private let keys: [VirtualMouseKey]
init(input: RemoteMouseInput) {
self.input = input
keys = [VirtualMouseKey(kind: .left, input: input),
VirtualMouseKey(kind: .wheel, input: input),
VirtualMouseKey(kind: .right, input: input)]
super.init(frame: .zero)
backgroundColor = .white
layer.cornerRadius = 18
layer.borderWidth = 0.5
layer.borderColor = UIColor(red: 212 / 255, green: 218 / 255, blue: 226 / 255, alpha: 1).cgColor
isMultipleTouchEnabled = true
keys.forEach(addSubview)
input.didCancel = { [weak self] in self?.keys.forEach { $0.cancelTracking() } }
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layoutSubviews() {
super.layoutSubviews()
let width = max(0, bounds.width - 8) / 3
for (index, key) in keys.enumerated() {
key.frame = CGRect(x: 4 + CGFloat(index) * width, y: 2,
width: width, height: max(0, bounds.height - 4))
}
}
}
private final class VirtualMouseKey: UIView {
enum Kind { case left, wheel, right }
private let kind: Kind
private let input: RemoteMouseInput
private let face = UIView()
private let faceMask = CAShapeLayer()
private let wheelIcon = VirtualMouseWheelView()
private var trackedTouch: UITouch?
private var lastPoint = CGPoint.zero
private var remainder: CGFloat = 0
init(kind: Kind, input: RemoteMouseInput) {
self.kind = kind
self.input = input
super.init(frame: .zero)
isMultipleTouchEnabled = false
isAccessibilityElement = true
accessibilityTraits = kind == .wheel ? [.adjustable] : [.button]
accessibilityLabel = kind == .left ? "鼠标左键" : kind == .right ? "鼠标右键" : "鼠标滚轮"
accessibilityHint = kind == .wheel ? "上下滑动,可滑出按键范围" : "按住并拖动,可滑出按键范围,松开结束拖拽"
face.isUserInteractionEnabled = false
face.layer.mask = faceMask
addSubview(face)
wheelIcon.isHidden = kind != .wheel
wheelIcon.isUserInteractionEnabled = false
addSubview(wheelIcon)
highlight(false)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layoutSubviews() {
super.layoutSubviews()
// All three controls keep a 44 × 44 pt hit area; only the wheel's face
// narrows to 18 pt. Empty gutters separate it from the two paddles.
face.frame = kind == .wheel
? CGRect(x: (bounds.width - 18) / 2, y: (bounds.height - 30) / 2, width: 18, height: 30)
: bounds.insetBy(dx: 1, dy: 2)
let leftRadius: CGFloat = kind == .left ? 14 : 6
let rightRadius: CGFloat = kind == .right ? 14 : 6
faceMask.frame = face.bounds
faceMask.path = facePath(in: face.bounds, leftRadius: leftRadius, rightRadius: rightRadius).cgPath
wheelIcon.frame = face.frame.insetBy(dx: 3, dy: 3)
}
private func facePath(in rect: CGRect, leftRadius: CGFloat, rightRadius: CGFloat) -> UIBezierPath {
let path = UIBezierPath()
let left = min(leftRadius, rect.height / 2, rect.width / 2)
let right = min(rightRadius, rect.height / 2, rect.width / 2)
path.move(to: CGPoint(x: rect.minX + left, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX - right, y: rect.minY))
path.addArc(withCenter: CGPoint(x: rect.maxX - right, y: rect.minY + right),
radius: right, startAngle: -.pi / 2, endAngle: 0, clockwise: true)
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - right))
path.addArc(withCenter: CGPoint(x: rect.maxX - right, y: rect.maxY - right),
radius: right, startAngle: 0, endAngle: .pi / 2, clockwise: true)
path.addLine(to: CGPoint(x: rect.minX + left, y: rect.maxY))
path.addArc(withCenter: CGPoint(x: rect.minX + left, y: rect.maxY - left),
radius: left, startAngle: .pi / 2, endAngle: .pi, clockwise: true)
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + left))
path.addArc(withCenter: CGPoint(x: rect.minX + left, y: rect.minY + left),
radius: left, startAngle: .pi, endAngle: .pi * 1.5, clockwise: true)
path.close()
return path
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard trackedTouch == nil, let touch = touches.first else { return }
trackedTouch = touch
lastPoint = touch.location(in: window)
remainder = 0
highlight(true)
if kind != .wheel { input.setVirtualButton(left: kind == .left, down: true) }
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = trackedTouch, touches.contains(touch) else { return }
let point = touch.location(in: window)
let delta = CGPoint(x: point.x - lastPoint.x, y: point.y - lastPoint.y)
lastPoint = point
if kind != .wheel {
input.drag(dx: Float(delta.x), dy: Float(delta.y))
return
}
wheelIcon.advance(by: delta.y)
remainder += delta.y
let ticks = Int(remainder / 10)
if ticks != 0 {
// UIKit keeps delivering this touch outside the 44 pt key. Finger
// down maps to negative wheel units (scroll down) on the desktop.
for _ in 0..<min(abs(ticks), 32) { input.scroll(ticks > 0 ? -1 : 1) }
remainder -= CGFloat(ticks) * 10
}
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = trackedTouch, touches.contains(touch) else { return }
cancelTracking()
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) { cancelTracking() }
func cancelTracking() {
if trackedTouch != nil, kind != .wheel {
input.setVirtualButton(left: kind == .left, down: false)
}
trackedTouch = nil
remainder = 0
highlight(false)
}
override func accessibilityActivate() -> Bool {
if kind == .wheel { accessibilityIncrement() }
else {
input.setVirtualButton(left: kind == .left, down: true)
input.setVirtualButton(left: kind == .left, down: false)
}
return true
}
override func accessibilityIncrement() {
if kind == .wheel { wheelIcon.advance(by: 10); input.scroll(-1) }
}
override func accessibilityDecrement() {
if kind == .wheel { wheelIcon.advance(by: -10); input.scroll(1) }
}
private func highlight(_ pressed: Bool) {
face.backgroundColor = pressed ? UIColor(red: 46 / 255, green: 122 / 255, blue: 219 / 255, alpha: 1)
: UIColor(red: 237 / 255, green: 240 / 255, blue: 244 / 255, alpha: 1)
wheelIcon.strokeColor = pressed ? .white
: UIColor(red: 48 / 255, green: 54 / 255, blue: 64 / 255, alpha: 0.72)
}
}
/// A clipped conveyor of wheel treads: follows touch movement and stops on lift.
private final class VirtualMouseWheelView: UIView {
var strokeColor = UIColor.black { didSet { setNeedsDisplay() } }
private var phase: CGFloat = 2
private let pitch: CGFloat = 5
init() {
super.init(frame: .zero)
isOpaque = false
backgroundColor = .clear
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func advance(by distance: CGFloat) {
phase = (phase + distance * 0.65).truncatingRemainder(dividingBy: pitch)
setNeedsDisplay()
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
UIBezierPath(roundedRect: bounds, cornerRadius: 2).addClip()
context.setFillColor(strokeColor.cgColor)
for index in -1...6 {
let y = CGFloat(index) * pitch + phase
context.fill(CGRect(x: 0, y: y, width: bounds.width, height: 1.5))
}
}
}
+302 -178
View File
@@ -31,6 +31,9 @@ private struct RemoteViewportState: Equatable {
struct RemoteSessionView: View {
@ObservedObject var session: RemoteSessionModel
@Environment(\.scenePhase) private var scenePhase
@State private var mouseInput = RemoteMouseInput()
@State private var virtualMouseVisible = false
@State private var keyboardInputVisible = false
@State private var keyboardMode: RemoteKeyboardMode = .system
@State private var viewport = RemoteViewportState()
@@ -81,6 +84,7 @@ struct RemoteSessionView: View {
}
RemoteTouchInputView(
mouseInput: mouseInput,
videoSize: session.displayGeometrySize,
controlMode: session.mouseControlMode,
remoteCursorPosition: session.remoteCursorPosition,
@@ -95,20 +99,24 @@ struct RemoteSessionView: View {
offset: offset)
},
move: { x, y in
mouseInput.position(x: x, y: y)
cursorPosition = CGPoint(x: CGFloat(x), y: CGFloat(y))
session.bridge.sendPointer(x: x, y: y, action: .move)
},
leftDown: { x, y in
session.bridge.sendPointer(x: x, y: y, action: .leftDown)
mouseInput.position(x: x, y: y)
mouseInput.setGestureLeft(true)
},
leftUp: { x, y in
session.bridge.sendPointer(x: x, y: y, action: .leftUp)
mouseInput.position(x: x, y: y)
mouseInput.setGestureLeft(false)
},
rightClick: { x, y in
session.bridge.sendPointer(x: x, y: y, action: .rightDown)
session.bridge.sendPointer(x: x, y: y, action: .rightUp)
},
scroll: { x, y, dx, dy in
mouseInput.position(x: x, y: y)
cursorPosition = CGPoint(x: CGFloat(x), y: CGFloat(y))
session.bridge.sendScrollX(x, y: y,
deltaX: dx, deltaY: dy)
@@ -160,7 +168,27 @@ struct RemoteSessionView: View {
} message: {
Text(session.remoteUpdateMessage ?? "")
}
.onAppear {
mouseInput.send = { x, y, flag, wheel in
cursorPosition = CGPoint(x: CGFloat(x), y: CGFloat(y))
switch flag {
case 0: session.bridge.sendPointer(x: x, y: y, action: .move)
case 1: session.bridge.sendPointer(x: x, y: y, action: .leftDown)
case 2: session.bridge.sendPointer(x: x, y: y, action: .leftUp)
case 3: session.bridge.sendPointer(x: x, y: y, action: .rightDown)
case 4: session.bridge.sendPointer(x: x, y: y, action: .rightUp)
case 7: session.bridge.sendScrollX(x, y: y, deltaX: 0, deltaY: wheel)
default: break
}
}
if let point = session.remoteCursorPosition {
mouseInput.position(x: Float(point.x), y: Float(point.y))
}
}
.onDisappear {
mouseInput.releaseAll()
mouseInput.send = nil
virtualMouseVisible = false
keyboardInputVisible = false
viewport = RemoteViewportState()
cursorPosition = nil
@@ -169,14 +197,27 @@ struct RemoteSessionView: View {
keyboardPanelCenter = nil
}
.onChange(of: session.selectedDisplay) { _ in
mouseInput.releaseAll()
mouseInput.position(x: 0.5, y: 0.5)
cursorPosition = nil
viewport = RemoteViewportState()
}
.onChange(of: session.mouseControlMode) { _ in
mouseInput.releaseAll()
cursorPosition = session.remoteCursorPosition
if let point = cursorPosition {
mouseInput.position(x: Float(point.x), y: Float(point.y))
}
}
.onChange(of: scenePhase) { phase in
if phase != .active { mouseInput.releaseAll() }
}
.onChange(of: session.isConnected) { connected in
if !connected { mouseInput.releaseAll() }
}
.onChange(of: session.remoteCursorPositionRevision) { _ in
guard let position = session.remoteCursorPosition else { return }
guard !mouseInput.hasVirtualButton, let position = session.remoteCursorPosition else { return }
mouseInput.position(x: Float(position.x), y: Float(position.y))
cursorPosition = position
}
.fileImporter(isPresented: $showingFileImporter,
@@ -271,17 +312,16 @@ struct RemoteSessionView: View {
y: restingOrbCenter.y + statusOrbDragTranslation.height),
in: containerSize
)
let panelSize = CGSize(
width: min(340, max(280, containerSize.width - 160)),
height: min(310, max(0, containerSize.height - 16))
)
let panelCenter = floatingPanelCenter(
orbCenter: orbCenter,
panelSize: panelSize,
containerSize: containerSize
)
let menuColumns = containerSize.height < 400 && containerSize.width >= 400 ? 4 : 3
let panelWidth = min(max(1, containerSize.width - 16), menuColumns == 4
? 380 : min(340, max(280, containerSize.width - 160)))
ZStack(alignment: .topLeading) {
if virtualMouseVisible && !statusMenuVisible && session.pixelBuffer != nil {
VirtualMouseBar(input: mouseInput)
.frame(width: 140, height: 48)
.position(virtualMouseCenter(orbCenter: orbCenter, in: containerSize))
}
if statusMenuVisible {
Color.clear
.contentShape(Rectangle())
@@ -292,13 +332,18 @@ struct RemoteSessionView: View {
}
}
// Position a stable container, then animate only the panel
// inside it. Scaling a view after .position also scales its
// full-screen positioning space and makes the panel drift.
ZStack {
FloatingMenuLayout(orbCenter: orbCenter, panelWidth: panelWidth) {
if statusMenuVisible {
FloatingSessionMenu(
session: session,
columnCount: menuColumns,
virtualMouseVisible: virtualMouseVisible,
toggleMouse: {
mouseInput.releaseAll()
virtualMouseVisible.toggle()
statusMenuVisible = false
},
releaseMouse: mouseInput.releaseAll,
showKeyboard: {
statusMenuVisible = false
keyboardInputVisible.toggle()
@@ -313,6 +358,7 @@ struct RemoteSessionView: View {
}
},
disconnect: {
mouseInput.releaseAll()
statusMenuVisible = false
keyboardInputVisible = false
withAnimation(.easeOut(duration: 0.18)) {
@@ -320,13 +366,11 @@ struct RemoteSessionView: View {
}
}
)
.frame(width: panelSize.width, height: panelSize.height)
.transition(.scale(scale: 0.94, anchor: .topTrailing)
.combined(with: .opacity))
}
}
.frame(width: panelSize.width, height: panelSize.height)
.position(panelCenter)
.frame(width: containerSize.width, height: containerSize.height)
.allowsHitTesting(statusMenuVisible)
CrossDeskStatusOrb(isExpanded: statusMenuVisible)
@@ -334,6 +378,7 @@ struct RemoteSessionView: View {
.contentShape(Circle())
.position(orbCenter)
.onTapGesture {
mouseInput.releaseAll()
withAnimation(.spring(response: 0.24,
dampingFraction: 0.84)) {
statusMenuVisible.toggle()
@@ -345,6 +390,7 @@ struct RemoteSessionView: View {
state = value.translation
}
.onChanged { _ in
mouseInput.releaseAll()
if statusMenuVisible {
statusMenuVisible = false
}
@@ -359,9 +405,40 @@ struct RemoteSessionView: View {
)
}
.frame(width: containerSize.width, height: containerSize.height)
.onChange(of: containerSize) { _ in mouseInput.releaseAll() }
}
}
private func virtualMouseCenter(orbCenter: CGPoint, in size: CGSize) -> CGPoint {
let halfWidth: CGFloat = 70
let halfHeight: CGFloat = 24
let reach: CGFloat = 27 + 6 + halfWidth
var x = orbCenter.x - reach
var y = orbCenter.y
if x - halfWidth < 8 {
x = orbCenter.x + reach
if x + halfWidth > size.width - 8 {
x = orbCenter.x
y = orbCenter.y + 27 + 6 + halfHeight
if y + halfHeight > size.height - 8 {
y = orbCenter.y - 27 - 6 - halfHeight
}
}
}
x = min(max(x, halfWidth + 8), max(halfWidth + 8, size.width - halfWidth - 8))
if keyboardInputVisible {
let keyboardSize = keyboardPanelSize(in: size)
let resting = constrainedKeyboardCenter(keyboardPanelCenter ?? defaultKeyboardCenter(in: size, panelSize: keyboardSize), in: size, panelSize: keyboardSize)
let center = constrainedKeyboardCenter(CGPoint(x: resting.x + keyboardDragTranslation.width, y: resting.y + keyboardDragTranslation.height), in: size, panelSize: keyboardSize)
let keyboardRect = CGRect(x: center.x - keyboardSize.width / 2, y: center.y - keyboardSize.height / 2, width: keyboardSize.width, height: keyboardSize.height)
if keyboardRect.intersects(CGRect(x: x - halfWidth, y: y - halfHeight, width: 140, height: 48)) {
y = keyboardRect.minY - 6 - halfHeight
if y - halfHeight < 8 { y = keyboardRect.maxY + 6 + halfHeight }
}
}
return CGPoint(x: x, y: min(max(y, halfHeight + 8), max(halfHeight + 8, size.height - halfHeight - 8)))
}
private var draggableKeyboard: some View {
GeometryReader { proxy in
let containerSize = proxy.size
@@ -503,24 +580,6 @@ struct RemoteSessionView: View {
)
}
private func floatingPanelCenter(orbCenter: CGPoint,
panelSize: CGSize,
containerSize: CGSize) -> CGPoint {
let gap: CGFloat = 12
let orbRadius: CGFloat = 27
let horizontalPadding: CGFloat = 8
let verticalPadding: CGFloat = 8
let preferredX = orbCenter.x > containerSize.width / 2
? orbCenter.x - orbRadius - gap - panelSize.width / 2
: orbCenter.x + orbRadius + gap + panelSize.width / 2
return CGPoint(
x: min(max(preferredX, panelSize.width / 2 + horizontalPadding),
containerSize.width - panelSize.width / 2 - horizontalPadding),
y: min(max(orbCenter.y, panelSize.height / 2 + verticalPadding),
containerSize.height - panelSize.height / 2 - verticalPadding)
)
}
private var visibleCursorShape: Int {
guard session.hasRemoteCursorState,
session.remoteCursorVisible,
@@ -1079,8 +1138,39 @@ private struct CrossDeskStatusOrb: View {
}
}
/// Measure the menu before positioning it; no fixed height or later-frame
/// preference update is needed when changing pages, text size or orientation.
private struct FloatingMenuLayout: Layout {
let orbCenter: CGPoint
let panelWidth: CGFloat
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews,
cache: inout ()) -> CGSize {
proposal.replacingUnspecifiedDimensions()
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize,
subviews: Subviews, cache: inout ()) {
guard let menu = subviews.first else { return }
let size = menu.sizeThatFits(ProposedViewSize(
width: panelWidth, height: max(0, bounds.height - 16)))
let preferredX = orbCenter.x > bounds.width / 2
? orbCenter.x - 39 - size.width / 2
: orbCenter.x + 39 + size.width / 2
let center = CGPoint(
x: bounds.minX + min(max(preferredX, size.width / 2 + 8),
bounds.width - size.width / 2 - 8),
y: bounds.minY + min(max(orbCenter.y, size.height / 2 + 8),
bounds.height - size.height / 2 - 8))
menu.place(at: center, anchor: .center, proposal: ProposedViewSize(size))
}
}
private struct FloatingSessionMenu: View {
@ObservedObject var session: RemoteSessionModel
let virtualMouseVisible: Bool
let toggleMouse: () -> Void
let releaseMouse: () -> Void
private enum Page { case controls, videoSettings, networkStats }
@State private var page: Page = .controls
@State private var displayedNetworkSnapshot: RemoteNetworkSnapshot
@@ -1090,15 +1180,25 @@ private struct FloatingSessionMenu: View {
let close: () -> Void
let disconnect: () -> Void
private let columns = Array(repeating: GridItem(.flexible(), spacing: 7),
count: 3)
let columnCount: Int
private var columns: [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: 7), count: columnCount)
}
init(session: RemoteSessionModel,
columnCount: Int,
virtualMouseVisible: Bool,
toggleMouse: @escaping () -> Void,
releaseMouse: @escaping () -> Void,
showKeyboard: @escaping () -> Void,
chooseFile: @escaping () -> Void,
close: @escaping () -> Void,
disconnect: @escaping () -> Void) {
self.session = session
self.columnCount = columnCount
self.virtualMouseVisible = virtualMouseVisible
self.toggleMouse = toggleMouse
self.releaseMouse = releaseMouse
self.showKeyboard = showKeyboard
self.chooseFile = chooseFile
self.close = close
@@ -1110,145 +1210,11 @@ private struct FloatingSessionMenu: View {
}
var body: some View {
VStack(spacing: 9) {
HStack(spacing: 8) {
if page != .controls {
Button {
page = .controls
} label: {
Image(systemName: "chevron.left")
.frame(width: 28, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("返回控制栏")
Text(page == .videoSettings ? "画面设置" : "网络状态")
.font(.subheadline.weight(.semibold))
} else {
Circle()
.fill(session.isConnected ? Color.green : Color.orange)
.frame(width: 9, height: 9)
VStack(alignment: .leading, spacing: 1) {
Text(session.connectionStatus)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(statusDetail)
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
Button(action: close) {
Image(systemName: "xmark.circle.fill")
.font(.title3)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("关闭控制栏")
}
if page == .videoSettings, !session.videoSettingsFeedback.isEmpty {
Text(session.videoSettingsFeedback)
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
ScrollView {
if page == .videoSettings {
FloatingVideoSettings(session: session)
} else if page == .networkStats {
FloatingNetworkStatistics(snapshot: displayedNetworkSnapshot,
frameSize: displayedFrameSize)
} else {
VStack(spacing: 9) {
LazyVGrid(columns: columns, spacing: 7) {
FloatingControlButton(title: "键盘", symbol: "keyboard",
action: showKeyboard)
Menu {
ForEach(Array(session.displays.enumerated()), id: \.offset) { index, name in
Button {
session.selectDisplay(index)
} label: {
if index == session.selectedDisplay {
Label(name, systemImage: "checkmark")
} else {
Text(name)
}
}
}
} label: {
FloatingControlLabel(title: "显示器", symbol: "display")
}
.buttonStyle(.plain)
FloatingControlButton(title: "画面设置", symbol: "slider.horizontal.3") {
page = .videoSettings
}
FloatingControlButton(
title: session.audioEnabled ? "声音" : "静音",
symbol: session.audioEnabled ? "speaker.wave.2" : "speaker.slash",
action: session.toggleAudio
)
FloatingControlButton(
title: session.mouseControlMode == .relative ? "相对鼠标" : "绝对鼠标",
symbol: "computermouse",
action: {
session.mouseControlMode = session.mouseControlMode == .relative
? .absolute : .relative
}
)
FloatingControlButton(title: "发送文件",
symbol: "folder.badge.plus",
action: chooseFile)
FloatingControlButton(title: "网络状态", symbol: "chart.bar.xaxis") {
page = .networkStats
}
FloatingControlButton(title: "Ctrl+Alt+Del",
symbol: "lock.trianglebadge.exclamationmark",
action: session.sendSecureAttention)
FloatingControlButton(title: "剪贴板", symbol: "doc.on.clipboard",
action: session.sendClipboard)
}
if !session.controlStatus.isEmpty {
Text(session.controlStatus).font(.caption2).foregroundStyle(.secondary)
}
if !session.clipboardStatus.isEmpty {
Text(session.clipboardStatus).font(.caption2).foregroundStyle(.secondary)
}
if !session.transferStatus.isEmpty {
HStack(spacing: 8) {
Text(session.transferStatus)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer(minLength: 0)
if session.transferProgress > 0 && session.transferProgress < 1 {
ProgressView(value: session.transferProgress)
.frame(width: 60)
}
if let file = session.receivedFileURL {
ShareLink(item: file) {
Image(systemName: "square.and.arrow.up")
}
}
}
}
}
}
}
Button(role: .destructive, action: disconnect) {
Text("断开连接")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.frame(height: 34)
}
.buttonStyle(.borderedProminent)
.tint(.red)
// Prefer the full natural-height panel. Only use a scrolling body when
// the available safe-area height cannot contain all of its content.
ViewThatFits(in: .vertical) {
panel(scrolls: false).fixedSize(horizontal: false, vertical: true)
panel(scrolls: true)
}
.padding(12)
.background(Color.white,
@@ -1274,6 +1240,164 @@ private struct FloatingSessionMenu: View {
}
}
private func panel(scrolls: Bool) -> some View {
VStack(spacing: 9) {
menuHeader
if scrolls {
ScrollView { menuContent }
} else {
menuContent
}
disconnectButton
}
}
@ViewBuilder private var menuHeader: some View {
HStack(spacing: 8) {
if page != .controls {
Button {
page = .controls
} label: {
Image(systemName: "chevron.left")
.frame(width: 28, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("返回控制栏")
Text(page == .videoSettings ? "画面设置" : "网络状态")
.font(.subheadline.weight(.semibold))
} else {
Circle()
.fill(session.isConnected ? Color.green : Color.orange)
.frame(width: 9, height: 9)
VStack(alignment: .leading, spacing: 1) {
Text(session.connectionStatus)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(statusDetail)
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
Button(action: close) {
Image(systemName: "xmark.circle.fill")
.font(.title3)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("关闭控制栏")
}
if page == .videoSettings, !session.videoSettingsFeedback.isEmpty {
Text(session.videoSettingsFeedback)
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@ViewBuilder private var menuContent: some View {
if page == .videoSettings {
FloatingVideoSettings(session: session)
} else if page == .networkStats {
FloatingNetworkStatistics(snapshot: displayedNetworkSnapshot,
frameSize: displayedFrameSize)
} else {
VStack(spacing: 9) {
LazyVGrid(columns: columns, spacing: 7) {
FloatingControlButton(title: "键盘", symbol: "keyboard",
action: showKeyboard)
FloatingControlButton(
title: session.mouseControlMode == .relative ? "相对鼠标" : "绝对鼠标",
symbol: "computermouse",
action: {
session.mouseControlMode = session.mouseControlMode == .relative
? .absolute : .relative
}
)
FloatingControlButton(title: virtualMouseVisible ? "收起鼠标" : "虚拟鼠标",
symbol: "computermouse", action: toggleMouse)
Menu {
ForEach(Array(session.displays.enumerated()), id: \.offset) { index, name in
Button {
releaseMouse()
session.selectDisplay(index)
} label: {
if index == session.selectedDisplay {
Label(name, systemImage: "checkmark")
} else {
Text(name)
}
}
}
} label: {
FloatingControlLabel(title: "显示器", symbol: "display")
}
.buttonStyle(.plain)
FloatingControlButton(title: "画面设置", symbol: "slider.horizontal.3") {
page = .videoSettings
}
FloatingControlButton(
title: session.audioEnabled ? "声音" : "静音",
symbol: session.audioEnabled ? "speaker.wave.2" : "speaker.slash",
action: session.toggleAudio
)
FloatingControlButton(title: "发送文件",
symbol: "folder.badge.plus",
action: chooseFile)
FloatingControlButton(title: "网络状态", symbol: "chart.bar.xaxis") {
page = .networkStats
}
FloatingControlButton(title: "Ctrl+Alt+Del",
symbol: "lock.trianglebadge.exclamationmark",
action: session.sendSecureAttention)
FloatingControlButton(title: "剪贴板", symbol: "doc.on.clipboard",
action: session.sendClipboard)
}
if !session.controlStatus.isEmpty {
Text(session.controlStatus).font(.caption2).foregroundStyle(.secondary)
}
if !session.clipboardStatus.isEmpty {
Text(session.clipboardStatus).font(.caption2).foregroundStyle(.secondary)
}
if !session.transferStatus.isEmpty {
HStack(spacing: 8) {
Text(session.transferStatus)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer(minLength: 0)
if session.transferProgress > 0 && session.transferProgress < 1 {
ProgressView(value: session.transferProgress)
.frame(width: 60)
}
if let file = session.receivedFileURL {
ShareLink(item: file) {
Image(systemName: "square.and.arrow.up")
}
}
}
}
}
}
}
private var disconnectButton: some View {
Button(role: .destructive, action: disconnect) {
Text("断开连接")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.frame(height: 34)
}
.buttonStyle(.borderedProminent)
.tint(.red)
}
private var statusDetail: String {
let snapshot = displayedNetworkSnapshot
var components = [snapshot.connectionMode]
+19
View File
@@ -327,6 +327,25 @@ Old previews saved without this choice are removed on upgrade.
## Physical-device test checklist
The floating control menu includes **虚拟鼠标** (virtual mouse), a 140 × 48 pt
horizontal left-button / wheel / right-button bar attached to the status orb.
Drag the orb to reposition it, or use **收起鼠标** to hide it. The bar overlays
the video without resizing it and avoids the floating keyboard. Hold either mouse
button and drag the same finger, including beyond the key bounds, to drag at the
remote cursor; lifting the finger releases that button. Moving another finger on
the video while holding a button also works. The flat split design has unlabelled
left/right paddles and an 18 × 30 pt wheel face, separated by clear gutters;
all three touch targets remain 44 × 44 pt. There are no shadows, bevels or press
displacement: pressing changes only the face color. Uniform horizontal wheel
treads cycle vertically with the finger and stop when it lifts.
Swipe the wheel up/down, including beyond its bounds, to scroll at any zoom
level. Check both pointer modes, button cancellation, display changes, rotation,
and backgrounding on a device; each must leave remote buttons released.
The expanded status menu sizes to all of its content, including all ten actions;
short, wide screens use four columns, and only content that exceeds the available
safe area scrolls. Check this in both
orientations, with larger text and while switching menu pages.
Run the app from Xcode on a physical device and connect to a current desktop
build. Test the features in this order so a media problem is not confused with
a data-channel problem: