Files
VoidLink/VoidLink/Input/AbstractGamepadOverlayWidgetView.swift
T
2026-07-01 15:20:57 +08:00

546 lines
21 KiB
Swift

//
// AbstractGamepadOverlayWidgetView.swift
// VoidLink
//
// Created by True砖家 on 2024/4/6.
// Copyright © 2026 True砖家 on Bilibili. All rights reserved.
//
import UIKit
import SwiftUI
@objc
protocol AbstractGamepadOverlayCloseButtonDelegate: AnyObject {
@objc(toggleGamepadOverlayWithOverlayEnabled:)
func toggleGamepadOverlay(withOverlayEnabled overlayEnabled: Bool)
}
private enum AbstractGamepadOverlayPersistence {
private static let defaults = UserDefaults.standard
private enum Key {
static let overlayCenterXRatio = "AbstractGamepadOverlay.overlay.centerXRatio"
static let overlayCenterYRatio = "AbstractGamepadOverlay.overlay.centerYRatio"
static let overlayWidth = "AbstractGamepadOverlay.overlay.width"
}
struct PersistedState {
let centerRatio: CGPoint
let size: CGSize
}
static func loadOverlayState() -> PersistedState? {
loadState(
centerXKey: Key.overlayCenterXRatio,
centerYKey: Key.overlayCenterYRatio,
widthKey: Key.overlayWidth
)
}
static func saveOverlayState(centerRatio: CGPoint, size: CGSize) {
saveState(
centerRatio: centerRatio,
size: size,
centerXKey: Key.overlayCenterXRatio,
centerYKey: Key.overlayCenterYRatio,
widthKey: Key.overlayWidth
)
}
static func centerRatio(for view: UIView, in superview: UIView) -> CGPoint {
let safeWidth = max(superview.bounds.width, 1)
let safeHeight = max(superview.bounds.height, 1)
return CGPoint(x: view.center.x / safeWidth, y: view.center.y / safeHeight)
}
static func center(in superview: UIView, from ratio: CGPoint) -> CGPoint {
CGPoint(
x: min(max(ratio.x, 0), 1) * superview.bounds.width,
y: min(max(ratio.y, 0), 1) * superview.bounds.height
)
}
private static func loadState(
centerXKey: String,
centerYKey: String,
widthKey: String
) -> PersistedState? {
guard defaults.object(forKey: centerXKey) != nil,
defaults.object(forKey: centerYKey) != nil,
defaults.object(forKey: widthKey) != nil else {
return nil
}
let width = defaults.double(forKey: widthKey)
guard width > 0 else { return nil }
return PersistedState(
centerRatio: CGPoint(
x: defaults.double(forKey: centerXKey),
y: defaults.double(forKey: centerYKey)
),
size: CGSize(width: width, height: 0)
)
}
private static func saveState(
centerRatio: CGPoint,
size: CGSize,
centerXKey: String,
centerYKey: String,
widthKey: String
) {
defaults.set(centerRatio.x, forKey: centerXKey)
defaults.set(centerRatio.y, forKey: centerYKey)
defaults.set(size.width, forKey: widthKey)
}
}
@available(iOS 13.0, *)
private struct AbstractGamepadOverlayBridgeView: View {
let usesPlayStationFaceButtons: Bool
private static let overlayClusterGapAdjustmentRatio: CGFloat = 0.13
private static let overlayUpperPrimaryClusterVerticalAdjustmentRatio: CGFloat = -0.05
private static let overlayCenterCompressionRatio: CGFloat = 0
private static let overlayPanelHorizontalInsetRatio: CGFloat = 0.083
private static let overlayTriggerStubSpacingAdjustmentRatio: CGFloat = 0.17
private static let overlayRightShoulderHorizontalOffsetRatio: CGFloat = 0.006
private static let overlayThumbPurpleStrength: CGFloat = 1.15
private static let overlayTriggerPurpleStrength: CGFloat = 2.0
@ObservedObject var stateCenter = GamepadOverlayStateCenter.shared
var body: some View {
AbstractGamepadView(
gamepadType: usesPlayStationFaceButtons ? GamepadType.ps : GamepadType.xbox,
metricsProfile: GamepadMetricsProfile.overlay,
clusterGapAdjustmentRatio: Self.overlayClusterGapAdjustmentRatio,
upperPrimaryClusterVerticalAdjustmentRatio: Self.overlayUpperPrimaryClusterVerticalAdjustmentRatio,
centerCompressionRatio: Self.overlayCenterCompressionRatio,
panelHorizontalInsetRatio: Self.overlayPanelHorizontalInsetRatio,
triggerStubSpacingAdjustmentRatio: Self.overlayTriggerStubSpacingAdjustmentRatio,
rightShoulderHorizontalOffsetRatio: Self.overlayRightShoulderHorizontalOffsetRatio,
thumbPurpleStrength: Self.overlayThumbPurpleStrength,
triggerPurpleStrength: Self.overlayTriggerPurpleStrength,
liveSnapshot: stateCenter.snapshot
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.allowsHitTesting(false)
.background(Color.clear)
}
}
@available(iOS 13.0, *)
@objc
final class AbstractGamepadOverlayView: UIView {
private enum CloseButtonMetrics {
static let size: CGFloat = 40
static let topInset: CGFloat = -10
static let trailingInset: CGFloat = 0
static let symbolPointSize: CGFloat = 16
static let hideDelay: TimeInterval = 3
}
private enum OverlayMaskMetrics {
static let widthRatio: CGFloat = 0.85
static let heightRatio: CGFloat = 1.03
static let cornerRadiusRatio: CGFloat = 0.326
static let verticalOffsetRatio: CGFloat = -0.006
static let topRectWidthRatio: CGFloat = 0.6
static let topRectHeightRatio: CGFloat = 0.15
static let topRectCornerRadiusRatio: CGFloat = 0.22
static let topRectVerticalOffsetRatio: CGFloat = -0.5
}
private var hostingControllerBox: AnyObject?
private weak var hostedView: UIView?
private let visualMaskLayer = CAShapeLayer()
private let usesPlayStationFaceButtons: Bool
private let baseRenderWidth: CGFloat = 420
private let baseAspectRatio: CGFloat = 1.82
private let initialDisplayWidth: CGFloat
private var pinchBaseWidth: CGFloat = 0
private var panStartCenter: CGPoint = .zero
private var activeGestureCount = 0
private var didRestorePersistedState = false
private var hideCloseButtonWorkItem: DispatchWorkItem?
private let minWidth: CGFloat = 70
private let maxWidth: CGFloat = 520
private let doubleTapWidths: [CGFloat] = [PublicUtils.isIPhone ? 90 : 130, PublicUtils.isIPhone ? 165 : 200]
private lazy var closeButton: UIButton = makeCloseButton()
@objc weak var closeButtonDelegate: AbstractGamepadOverlayCloseButtonDelegate?
@objc init(frame: CGRect, usesPlayStationFaceButtons: Bool) {
self.usesPlayStationFaceButtons = usesPlayStationFaceButtons
self.initialDisplayWidth = frame.width > 0 ? frame.width : baseRenderWidth
super.init(frame: CGRect(origin: frame.origin, size: CGSize(width: baseRenderWidth, height: baseRenderWidth / baseAspectRatio)))
setupHostedViewIfNeeded()
}
override init(frame: CGRect) {
self.usesPlayStationFaceButtons = false
self.initialDisplayWidth = frame.width > 0 ? frame.width : baseRenderWidth
super.init(frame: CGRect(origin: frame.origin, size: CGSize(width: baseRenderWidth, height: baseRenderWidth / baseAspectRatio)))
setupHostedViewIfNeeded()
}
required init?(coder: NSCoder) {
self.usesPlayStationFaceButtons = false
self.initialDisplayWidth = baseRenderWidth
super.init(coder: coder)
setupHostedViewIfNeeded()
}
private func setupHostedViewIfNeeded() {
guard hostedView == nil else { return }
let hostingController = UIHostingController(rootView: AbstractGamepadOverlayBridgeView(usesPlayStationFaceButtons: usesPlayStationFaceButtons))
hostingController.view.backgroundColor = .clear
hostingController.view.isUserInteractionEnabled = false
hostingController.view.translatesAutoresizingMaskIntoConstraints = true
hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
hostingController.view.frame = bounds
hostingControllerBox = hostingController
hostedView = hostingController.view
backgroundColor = .clear
clipsToBounds = false
isMultipleTouchEnabled = true
applyScale(forTargetWidth: initialDisplayWidth)
addSubview(hostingController.view)
addSubview(closeButton)
installGesturesIfNeeded()
}
override func layoutSubviews() {
super.layoutSubviews()
hostedView?.frame = bounds
layoutCloseButton()
updateVisualMask()
restorePersistedStateIfNeeded()
}
override func didMoveToSuperview() {
super.didMoveToSuperview()
didRestorePersistedState = false
restorePersistedStateIfNeeded()
}
private func installGesturesIfNeeded() {
guard gestureRecognizers?.isEmpty != false else { return }
let singleTap = UITapGestureRecognizer(target: self, action: #selector(handleSingleTap(_:)))
singleTap.numberOfTapsRequired = 1
singleTap.cancelsTouchesInView = true
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
pan.minimumNumberOfTouches = 1
pan.maximumNumberOfTouches = 1
pan.cancelsTouchesInView = true
let pinch = UIPinchGestureRecognizer(target: self, action: #selector(handlePinch(_:)))
pinch.cancelsTouchesInView = true
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:)))
doubleTap.numberOfTapsRequired = 2
doubleTap.cancelsTouchesInView = true
singleTap.delegate = self
pan.delegate = self
pinch.delegate = self
doubleTap.delegate = self
pan.require(toFail: doubleTap)
singleTap.require(toFail: pan)
addGestureRecognizer(pan)
addGestureRecognizer(singleTap)
addGestureRecognizer(pinch)
addGestureRecognizer(doubleTap)
}
@objc private func handleSingleTap(_ recognizer: UITapGestureRecognizer) {
guard recognizer.state == .ended else { return }
registerUserInteraction()
presentFirstTouchTipIfNeeded()
scheduleCloseButtonHideIfNeeded()
}
@objc private func handlePan(_ recognizer: UIPanGestureRecognizer) {
guard let superview else { return }
switch recognizer.state {
case .began:
registerUserInteraction()
presentFirstTouchTipIfNeeded()
beginGestureSession()
panStartCenter = center
case .changed, .ended:
registerUserInteraction()
let translation = recognizer.translation(in: superview)
center = CGPoint(x: panStartCenter.x + translation.x, y: panStartCenter.y + translation.y)
if recognizer.state == .ended {
endGestureSession()
}
case .cancelled, .failed:
endGestureSession()
default:
break
}
}
@objc private func handlePinch(_ recognizer: UIPinchGestureRecognizer) {
switch recognizer.state {
case .began:
registerUserInteraction()
presentFirstTouchTipIfNeeded()
beginGestureSession()
pinchBaseWidth = frame.width
case .changed, .ended:
registerUserInteraction()
let nextWidth = max(minWidth, min(maxWidth, pinchBaseWidth * recognizer.scale))
applyScale(forTargetWidth: nextWidth)
if recognizer.state == .ended {
endGestureSession()
}
case .cancelled, .failed:
endGestureSession()
default:
break
}
}
@objc private func handleDoubleTap(_ recognizer: UITapGestureRecognizer) {
guard recognizer.state == .ended else { return }
registerUserInteraction()
presentFirstTouchTipIfNeeded()
applyScale(forTargetWidth: nextDoubleTapWidth())
persistState()
scheduleCloseButtonHideIfNeeded()
}
private func updateVisualMask() {
guard bounds.width > 1, bounds.height > 1 else {
visualMaskLayer.path = nil
hostedView?.layer.mask = nil
return
}
let maskWidth = bounds.width * OverlayMaskMetrics.widthRatio
let maskHeight = bounds.height * OverlayMaskMetrics.heightRatio
let maskRect = CGRect(
x: (bounds.width - maskWidth) * 0.5,
y: (bounds.height - maskHeight) * 0.5 + bounds.height * OverlayMaskMetrics.verticalOffsetRatio,
width: maskWidth,
height: maskHeight
)
let maskCornerRadius = min(maskRect.width, maskRect.height) * OverlayMaskMetrics.cornerRadiusRatio
let topRectWidth = bounds.width * OverlayMaskMetrics.topRectWidthRatio
let topRectHeight = bounds.height * OverlayMaskMetrics.topRectHeightRatio
let topRect = CGRect(
x: (bounds.width - topRectWidth) * 0.5,
y: (bounds.height - topRectHeight) * 0.5 + bounds.height * OverlayMaskMetrics.topRectVerticalOffsetRatio,
width: topRectWidth,
height: topRectHeight
)
let topRectCornerRadius = min(topRect.width, topRect.height) * OverlayMaskMetrics.topRectCornerRadiusRatio
visualMaskLayer.frame = bounds
let path = UIBezierPath(
roundedRect: maskRect,
byRoundingCorners: .allCorners,
cornerRadii: CGSize(width: maskCornerRadius, height: maskCornerRadius)
)
path.append(
UIBezierPath(
roundedRect: topRect,
byRoundingCorners: .allCorners,
cornerRadii: CGSize(width: topRectCornerRadius, height: topRectCornerRadius)
)
)
visualMaskLayer.path = path.cgPath
hostedView?.layer.mask = visualMaskLayer
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
bounds.contains(point)
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
registerUserInteraction()
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesMoved(touches, with: event)
registerUserInteraction()
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
scheduleCloseButtonHideIfNeeded()
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesCancelled(touches, with: event)
scheduleCloseButtonHideIfNeeded()
}
private func restorePersistedStateIfNeeded() {
guard !didRestorePersistedState,
let superview,
bounds.width > 0,
bounds.height > 0,
superview.bounds.width > 0,
superview.bounds.height > 0 else { return }
defer { didRestorePersistedState = true }
guard let state = AbstractGamepadOverlayPersistence.loadOverlayState() else { return }
applyScale(forTargetWidth: state.size.width)
center = AbstractGamepadOverlayPersistence.center(in: superview, from: state.centerRatio)
}
private func beginGestureSession() {
activeGestureCount += 1
}
@objc func registerUserInteraction() {
showCloseButton()
cancelPendingCloseButtonHide()
}
private func presentFirstTouchTipIfNeeded() {
guard GenericUtils.isFirstLaunchGamepadOverlayFeature() else { return }
guard let presentingViewController = nearestViewController() else { return }
AlertControllerUtil.showAlert(
in: presentingViewController,
title: GenericUtils.gamepadOverlayFeatureTipTitle(),
message: GenericUtils.gamepadOverlayFeatureTipMessage(),
withCancel: false,
buttonTitle: LocalizationHelper.localizedString(forKey: "This tip won't be shown again"),
countdown: 6
)
}
private func nearestViewController() -> UIViewController? {
var responder: UIResponder? = self
while let current = responder {
if let viewController = current as? UIViewController {
return viewController
}
responder = current.next
}
return nil
}
private func endGestureSession() {
activeGestureCount = max(0, activeGestureCount - 1)
guard activeGestureCount == 0 else { return }
persistState()
scheduleCloseButtonHideIfNeeded()
}
private func persistState() {
guard let superview,
superview.bounds.width > 0,
superview.bounds.height > 0 else { return }
AbstractGamepadOverlayPersistence.saveOverlayState(
centerRatio: AbstractGamepadOverlayPersistence.centerRatio(for: self, in: superview),
size: frame.size
)
}
private func nextDoubleTapWidth() -> CGFloat {
let currentWidth = frame.width
return abs(currentWidth - doubleTapWidths[0]) <= abs(currentWidth - doubleTapWidths[1])
? doubleTapWidths[1]
: doubleTapWidths[0]
}
private func applyScale(forTargetWidth targetWidth: CGFloat) {
let clampedWidth = max(minWidth, min(maxWidth, targetWidth))
let targetScale = clampedWidth / baseRenderWidth
transform = CGAffineTransform(scaleX: targetScale, y: targetScale)
}
private func makeCloseButton() -> UIButton {
let button = UIButton(type: .system)
button.tintColor = UIColor.black.withAlphaComponent(0.66)
button.backgroundColor = UIColor.white.withAlphaComponent(0.78)
button.layer.borderWidth = 1
button.layer.borderColor = UIColor.white.withAlphaComponent(0.9).cgColor
button.layer.shadowColor = UIColor.black.withAlphaComponent(0.05).cgColor
button.layer.shadowOpacity = 1
button.layer.shadowRadius = 4
button.layer.shadowOffset = CGSize(width: 0, height: 2)
button.alpha = 0
button.isHidden = true
button.adjustsImageWhenHighlighted = false
button.addTarget(self, action: #selector(handleCloseButtonTap), for: .touchUpInside)
let configuration = UIImage.SymbolConfiguration(pointSize: CloseButtonMetrics.symbolPointSize, weight: .bold)
button.setImage(UIImage(systemName: "xmark", withConfiguration: configuration), for: .normal)
return button
}
private func layoutCloseButton() {
let size = CGFloat(Int(CloseButtonMetrics.size/2))*2
closeButton.frame = CGRect(
x: bounds.width - CloseButtonMetrics.trailingInset - size,
y: CloseButtonMetrics.topInset,
width: size,
height: size
)
closeButton.layer.cornerRadius = size * 0.5
}
private func showCloseButton() {
closeButton.isHidden = false
guard closeButton.alpha < 1 else { return }
UIView.animate(withDuration: 0.18) {
self.closeButton.alpha = 1
}
}
private func hideCloseButton() {
guard !closeButton.isHidden else { return }
UIView.animate(
withDuration: 0.18,
animations: {
self.closeButton.alpha = 0
},
completion: { _ in
self.closeButton.isHidden = true
}
)
}
private func cancelPendingCloseButtonHide() {
hideCloseButtonWorkItem?.cancel()
hideCloseButtonWorkItem = nil
}
@objc func scheduleCloseButtonHideIfNeeded() {
guard activeGestureCount == 0 else { return }
cancelPendingCloseButtonHide()
let workItem = DispatchWorkItem { [weak self] in
self?.hideCloseButton()
self?.hideCloseButtonWorkItem = nil
}
hideCloseButtonWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + CloseButtonMetrics.hideDelay, execute: workItem)
}
@objc private func handleCloseButtonTap() {
cancelPendingCloseButtonHide()
closeButtonDelegate?.toggleGamepadOverlay(withOverlayEnabled: false)
}
}
@available(iOS 13.0, *)
extension AbstractGamepadOverlayView: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
(gestureRecognizer is UIPanGestureRecognizer && otherGestureRecognizer is UIPinchGestureRecognizer)
|| (gestureRecognizer is UIPinchGestureRecognizer && otherGestureRecognizer is UIPanGestureRecognizer)
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
guard let touchedView = touch.view else { return true }
return touchedView !== closeButton && !touchedView.isDescendant(of: closeButton)
}
}