// // RadialMenuView.swift // VoidLink // // Created by True砖家 on 2026/6/29. // Copyright © 2026 True砖家 @ Bilibili. All rights reserved. // import SwiftUI import Combine import UIKit @objc public enum RadialMenuItem:Int { case settings case favoriteSettings case allSettings case addHost case aboutView case hostView case gameProfiles case disconnect case mouse case quitApp case theme case more case navigationSettings case exit case toolbox } @objc public enum RadialMenuState:Int { case main case mouseModeEnabled case disconnectAndQuit case moreOptions } @available(iOS 13.0, *) struct RadialMenuSector: Identifiable, Equatable { let id = UUID() let title: String let subtitle: String let symbolName: String let item: RadialMenuItem init(title: String, subtitle: String, symbol: String, item: RadialMenuItem) { self.title = title self.subtitle = subtitle self.symbolName = symbol self.item = item } } @available(iOS 13.0, *) struct RadialMenuStyle: Equatable { var ringColor = Color(white: 0.86) var selectedRingColor = Color(red: 0.19, green: 0.72, blue: 0.96) var ringStrokeColor = Color.white.opacity(0.72) var centerFillColor = Color(ThemeManager.hostViewBackgroundColor.withAlphaComponent(0.5)) var iconColor = Color(white: 0.23) var selectedIconColor = Color(white: 0.16) var titleColor = Color(white: 0.24) var subtitleColor = Color(white: 0.48) var shadowColor = Color.black.opacity(0.18) var ringWidthRatio: CGFloat = 0.41 var segmentGapWidth: CGFloat = 1 var centerIconScale: CGFloat = 0.13 var segmentIconScale: CGFloat = 0.085 static func themed(for userInterfaceStyle: UIUserInterfaceStyle, accentColor: UIColor) -> RadialMenuStyle { switch userInterfaceStyle { case .dark: return RadialMenuStyle( ringColor: Color(UIColor(red: 44.0 / 255.0, green: 44.0 / 255.0, blue: 46.0 / 255.0, alpha: 0.88)), selectedRingColor: Color(accentColor.withAlphaComponent(0.52)), ringStrokeColor: Color.white.opacity(0.26), // centerFillColor: Color(UIColor(red: 28.0 / 255.0, green: 28.0 / 255.0, blue: 30.0 / 255.0, alpha: 0.96)), centerFillColor: Color(ThemeManager.hostViewBackgroundColor.withAlphaComponent(0.5)), iconColor: Color(UIColor(white: 0.86, alpha: 1)), selectedIconColor: Color.white, titleColor: Color(UIColor(white: 0.92, alpha: 1)), subtitleColor: Color(UIColor(white: 0.70, alpha: 1)), shadowColor: Color(accentColor.withAlphaComponent(0)), ringWidthRatio: 0.41, segmentGapWidth: 1, centerIconScale: 0.16, segmentIconScale: 0.085 ) default: return RadialMenuStyle( selectedRingColor: Color(accentColor.withAlphaComponent(0.86)) ) } } } @available(iOS 13.0, *) enum RadialMenuSelectionChangeReason { case began case moved case released case unchanged } @available(iOS 13.0, *) final class RadialMenuSelectionState: ObservableObject { @Published private(set) var selectedIndex: Int? @Published private(set) var hasReceivedJoystickInput = false @Published private(set) var lastChangeReason: RadialMenuSelectionChangeReason = .unchanged private var itemCount = 0 func updateSelection(xOffset: CGFloat, yOffset: CGFloat) { updateSelection(xOffset: xOffset, yOffset: yOffset, deadZone: 0.1) } func updateSelection(xOffset: CGFloat, yOffset: CGFloat, deadZone: CGFloat) { let x = max(min(xOffset, 1), -1) let y = max(min(yOffset, 1), -1) let distance = hypot(x, y) if distance < deadZone { guard !hasReceivedJoystickInput || selectedIndex != nil else { lastChangeReason = .unchanged return } hasReceivedJoystickInput = true lastChangeReason = selectedIndex == nil ? .unchanged : .released selectedIndex = nil return } // Near center, tiny return-to-center offsets have unstable angles. Keep the // last high-confidence sector until the stick fully enters the dead zone. let selectionUpdateDeadZone = max(deadZone, 0.35) guard distance >= selectionUpdateDeadZone else { hasReceivedJoystickInput = true lastChangeReason = .unchanged return } let nextSelectedIndex = Self.selectedIndex( xOffset: x, yOffset: y, itemCount: itemCount, deadZone: deadZone ) guard !hasReceivedJoystickInput || nextSelectedIndex != selectedIndex else { lastChangeReason = .unchanged return } let previousSelectedIndex = selectedIndex hasReceivedJoystickInput = true selectedIndex = nextSelectedIndex lastChangeReason = Self.changeReason(from: previousSelectedIndex, to: nextSelectedIndex) } fileprivate func configureItemCount(_ itemCount: Int) { self.itemCount = max(itemCount, 0) if let selectedIndex, selectedIndex >= itemCount { self.selectedIndex = nil } } static func selectedIndex(xOffset: CGFloat, yOffset: CGFloat, itemCount: Int, deadZone: CGFloat = 0.1) -> Int? { guard itemCount > 0 else { return nil } let x = max(min(xOffset, 1), -1) let y = max(min(yOffset, 1), -1) let distance = hypot(x, y) guard distance >= deadZone else { return nil } let clockwiseDegreesFromWest = positiveRemainder(atan2(Double(y), -Double(x)) * 180 / .pi, 360) let segmentDegrees = 360 / Double(itemCount) let centeredDegrees = positiveRemainder(clockwiseDegreesFromWest + segmentDegrees / 2, 360) return min(Int(centeredDegrees / segmentDegrees), itemCount - 1) } private static func positiveRemainder(_ value: Double, _ divisor: Double) -> Double { let remainder = value.truncatingRemainder(dividingBy: divisor) return remainder >= 0 ? remainder : remainder + divisor } private static func changeReason(from previousIndex: Int?, to nextIndex: Int?) -> RadialMenuSelectionChangeReason { switch (previousIndex, nextIndex) { case (nil, .some): return .began case (.some(let previous), .some(let next)) where previous != next: return .moved case (.some, nil): return .released default: return .unchanged } } } @available(iOS 13.0, *) struct RadialMenuView: View { let sectors: [RadialMenuSector] var selectedIndex: Int? var isTouchSelectionEnabled: Bool var style: RadialMenuStyle @ObservedObject private var joystickSelectionState: RadialMenuSelectionState @SwiftUI.State private var touchSelectedIndex: Int? private var effectiveSelectedIndex: Int? { if isTouchSelectionEnabled, let touchSelectedIndex { return touchSelectedIndex } if joystickSelectionState.hasReceivedJoystickInput { return joystickSelectionState.selectedIndex } return selectedIndex } private var selectedItem: RadialMenuSector? { guard let selectedIndex = effectiveSelectedIndex, sectors.indices.contains(selectedIndex) else { return sectors.first } return sectors[selectedIndex] } init( sectors: [RadialMenuSector], selectedIndex: Int? = nil, isTouchSelectionEnabled: Bool = true, style: RadialMenuStyle = RadialMenuStyle(), selectionState: RadialMenuSelectionState = RadialMenuSelectionState() ) { self.sectors = sectors self.selectedIndex = selectedIndex self.isTouchSelectionEnabled = isTouchSelectionEnabled self.style = style self.joystickSelectionState = selectionState self.joystickSelectionState.configureItemCount(sectors.count) } func updateSelection(xOffset: CGFloat, yOffset: CGFloat) { joystickSelectionState.configureItemCount(sectors.count) joystickSelectionState.updateSelection(xOffset: xOffset, yOffset: yOffset) } var body: some View { GeometryReader { proxy in let diameter = min(proxy.size.width, proxy.size.height) let center = CGPoint(x: proxy.size.width / 2, y: proxy.size.height / 2) let outerRadius = diameter / 2 let innerRadius = outerRadius * (1 - style.ringWidthRatio) ZStack { ForEach(sectors.indices, id: \.self) { index in RadialMenuSegmentShape( index: index, count: sectors.count, innerRadiusRatio: 1 - style.ringWidthRatio, gapWidth: style.segmentGapWidth ) .fill(index == effectiveSelectedIndex ? style.selectedRingColor : style.ringColor) .overlay( RadialMenuSegmentShape( index: index, count: sectors.count, innerRadiusRatio: 1 - style.ringWidthRatio, gapWidth: style.segmentGapWidth ) .stroke(style.ringStrokeColor.opacity(index == effectiveSelectedIndex ? 0.95 : 0.28), lineWidth: index == effectiveSelectedIndex ? 1.6 : 0.8) ) .scaleEffect(index == effectiveSelectedIndex ? 1.018 : 1) .animation(.spring(response: 0.24, dampingFraction: 0.78), value: effectiveSelectedIndex) } Circle() .fill(style.centerFillColor) .frame(width: innerRadius * 2, height: innerRadius * 2) .shadow(color: style.shadowColor, radius: 10, x: 0, y: 3) ForEach(sectors.indices, id: \.self) { index in let item = sectors[index] RadialMenuIconView( symbolName: item.symbolName, fallbackSystemName: "circle", size: max(diameter * style.segmentIconScale, 18), weight: .medium, color: index == effectiveSelectedIndex ? style.selectedIconColor : style.iconColor ) .scaleEffect(index == effectiveSelectedIndex ? 1.18 : 1) .position(iconPosition(index: index, count: sectors.count, center: center, radius: (outerRadius + innerRadius) / 2)) .animation(.spring(response: 0.24, dampingFraction: 0.72), value: effectiveSelectedIndex) } RadialMenuCenterView(item: selectedItem, style: style, diameter: diameter) .frame(width: innerRadius * 1.72, height: innerRadius * 1.72) .position(center) .animation(.easeInOut(duration: selectedItem?.item == .gameProfiles ? 0 : 0.16), value: effectiveSelectedIndex) } .frame(width: proxy.size.width, height: proxy.size.height) .contentShape(Circle()) .gesture(touchSelectionGesture(center: center, innerRadius: innerRadius, outerRadius: outerRadius)) } .aspectRatio(1, contentMode: .fit) } private func touchSelectionGesture(center: CGPoint, innerRadius: CGFloat, outerRadius: CGFloat) -> some Gesture { DragGesture(minimumDistance: 0, coordinateSpace: .local) .onChanged { value in guard isTouchSelectionEnabled else { return } touchSelectedIndex = index(for: value.location, center: center, innerRadius: innerRadius, outerRadius: outerRadius) } .onEnded { _ in guard isTouchSelectionEnabled else { return } withAnimation(.easeOut(duration: 0.18)) { touchSelectedIndex = nil } } } private func index(for location: CGPoint, center: CGPoint, innerRadius: CGFloat, outerRadius: CGFloat) -> Int? { guard !sectors.isEmpty else { return nil } let dx = location.x - center.x let dy = location.y - center.y let distance = hypot(dx, dy) guard distance >= innerRadius * 0.72, distance <= outerRadius * 1.08 else { return nil } let clockwiseDegreesFromWest = positiveRemainder(atan2(Double(-dy), Double(-dx)) * 180 / Double.pi, 360) let segmentDegrees = 360 / Double(sectors.count) let centeredDegrees = positiveRemainder(clockwiseDegreesFromWest + segmentDegrees / 2, 360) return min(Int(centeredDegrees / segmentDegrees), sectors.count - 1) } private func iconPosition(index: Int, count: Int, center: CGPoint, radius: CGFloat) -> CGPoint { guard count > 0 else { return center } let segmentDegrees = 360 / Double(count) let angleDegrees = 180 + Double(index) * segmentDegrees let radians = angleDegrees * .pi / 180 return CGPoint( x: center.x + CGFloat(cos(radians)) * radius, y: center.y + CGFloat(sin(radians)) * radius ) } private func positiveRemainder(_ value: Double, _ divisor: Double) -> Double { let remainder = value.truncatingRemainder(dividingBy: divisor) return remainder >= 0 ? remainder : remainder + divisor } } @available(iOS 13.0, *) private struct RadialMenuCenterView: View { let item: RadialMenuSector? let style: RadialMenuStyle let diameter: CGFloat var body: some View { VStack(spacing: 0) { RadialMenuIconView( symbolName: item?.symbolName ?? "circle.grid.cross", fallbackSystemName: "circle.grid.cross", size: centerIconSize, weight: .medium, color: style.iconColor ) .id(item?.id) .transition(.opacity) .frame(width: centerIconSize * 1.35, height: centerIconSize * 1.2) .padding(.bottom, centerIconTitleSpacing) Text(item?.title ?? "") .font(.system(size: max(diameter * 0.047, 13), weight: .semibold)) .foregroundColor(style.titleColor) .lineLimit(1) .minimumScaleFactor(0.62) .padding(.bottom, item?.subtitle.isEmpty == false ? centerTextSpacing : 0) if item?.subtitle.isEmpty == false { Text(item?.subtitle ?? "") .font(.system(size: max(diameter * 0.032, 10), weight: .regular)) .foregroundColor(style.subtitleColor) .lineLimit(1) .minimumScaleFactor(0.68) } } .multilineTextAlignment(.center) .padding(.horizontal, diameter * 0.035) } private var centerIconSize: CGFloat { max(diameter * style.centerIconScale, 28) } private var centerTextSpacing: CGFloat { max(diameter * 0.013, 3.3) } private var centerIconTitleSpacing: CGFloat { if #available(iOS 14.0, *) { return centerTextSpacing } return 3.6 } } @available(iOS 13.0, *) private struct RadialMenuIconView: View { let symbolName: String let fallbackSystemName: String let size: CGFloat let weight: Font.Weight let color: Color var body: some View { Group { if UIImage(systemName: symbolName) != nil { Image(systemName: symbolName) .font(.system(size: size, weight: weight)) } else if let assetImage = RadialMenuIconImageRenderer.templateImage(named: symbolName, pointSize: size) { Image(uiImage: assetImage) .renderingMode(.template) } else { Image(systemName: fallbackSystemName) .font(.system(size: size, weight: weight)) } } .foregroundColor(color) } } @available(iOS 13.0, *) private enum RadialMenuIconImageRenderer { private static let cache = NSCache() private static let assetCanvasScale: CGFloat = 1.05 private static let assetVisualScale: CGFloat = 1 static func templateImage(named name: String, pointSize: CGFloat) -> UIImage? { let scale = UIScreen.main.scale let normalizedPointSize = max(ceil(pointSize * 2) / 2, 1) let cacheKey = "\(name)-\(normalizedPointSize)-\(scale)-\(assetCanvasScale)-\(assetVisualScale)" as NSString if let cachedImage = cache.object(forKey: cacheKey) { return cachedImage } guard let sourceImage = UIImage(named: name) else { return nil } let canvasSize = CGSize( width: normalizedPointSize * assetCanvasScale, height: normalizedPointSize * assetCanvasScale ) let format = UIGraphicsImageRendererFormat() format.scale = scale format.opaque = false let renderedImage = UIGraphicsImageRenderer(size: canvasSize, format: format).image { _ in let drawBounds = scaledRect( CGRect(origin: .zero, size: canvasSize), by: assetVisualScale ) sourceImage.draw(in: aspectFitRect(for: sourceImage.size, in: drawBounds)) }.withRenderingMode(.alwaysTemplate) cache.setObject(renderedImage, forKey: cacheKey) return renderedImage } private static func scaledRect(_ rect: CGRect, by scale: CGFloat) -> CGRect { let width = rect.width * scale let height = rect.height * scale return CGRect( x: rect.midX - width / 2, y: rect.midY - height / 2, width: width, height: height ) } private static func aspectFitRect(for sourceSize: CGSize, in bounds: CGRect) -> CGRect { guard sourceSize.width > 0, sourceSize.height > 0 else { return bounds } let scale = min(bounds.width / sourceSize.width, bounds.height / sourceSize.height) let width = sourceSize.width * scale let height = sourceSize.height * scale return CGRect( x: bounds.midX - width / 2, y: bounds.midY - height / 2, width: width, height: height ) } } @available(iOS 13.0, *) private struct RadialMenuSegmentShape: Shape { let index: Int let count: Int let innerRadiusRatio: CGFloat let gapWidth: CGFloat func path(in rect: CGRect) -> Path { guard count > 0 else { return Path() } let center = CGPoint(x: rect.midX, y: rect.midY) let outerRadius = min(rect.width, rect.height) / 2 let innerRadius = outerRadius * innerRadiusRatio let segmentDegrees = 360 / Double(count) let centerDegrees = 180 + Double(index) * segmentDegrees let startBoundaryDegrees = centerDegrees - segmentDegrees / 2 let endBoundaryDegrees = centerDegrees + segmentDegrees / 2 let halfGap = min(gapWidth / 2, max(innerRadius - 1, 0)) var path = Path() let outerStart = offsetBoundaryPoint(center: center, radius: outerRadius, boundaryDegrees: startBoundaryDegrees, inwardSign: 1, halfGap: halfGap) let innerStart = offsetBoundaryPoint(center: center, radius: innerRadius, boundaryDegrees: startBoundaryDegrees, inwardSign: 1, halfGap: halfGap) let outerEnd = offsetBoundaryPoint(center: center, radius: outerRadius, boundaryDegrees: endBoundaryDegrees, inwardSign: -1, halfGap: halfGap) let innerEnd = offsetBoundaryPoint(center: center, radius: innerRadius, boundaryDegrees: endBoundaryDegrees, inwardSign: -1, halfGap: halfGap) let outerStartDegrees = degrees(from: center, to: outerStart) let outerEndDegrees = unwrappedEndDegrees(start: outerStartDegrees, end: degrees(from: center, to: outerEnd)) let innerStartDegrees = degrees(from: center, to: innerStart) let innerEndDegrees = unwrappedEndDegrees(start: innerStartDegrees, end: degrees(from: center, to: innerEnd)) path.move(to: outerStart) let outerSteps = max(Int((outerEndDegrees - outerStartDegrees) / 4), 6) for step in 1...outerSteps { let progress = Double(step) / Double(outerSteps) let degrees = outerStartDegrees + (outerEndDegrees - outerStartDegrees) * progress path.addLine(to: point(center: center, radius: outerRadius, degrees: degrees)) } path.addLine(to: innerEnd) let innerSteps = max(Int((innerEndDegrees - innerStartDegrees) / 4), 6) for step in 0...innerSteps { let progress = Double(step) / Double(innerSteps) let degrees = innerEndDegrees - (innerEndDegrees - innerStartDegrees) * progress path.addLine(to: point(center: center, radius: innerRadius, degrees: degrees)) } path.addLine(to: outerStart) path.closeSubpath() return path } private func offsetBoundaryPoint( center: CGPoint, radius: CGFloat, boundaryDegrees: Double, inwardSign: CGFloat, halfGap: CGFloat ) -> CGPoint { let radians = boundaryDegrees * .pi / 180 let radialX = CGFloat(cos(radians)) let radialY = CGFloat(sin(radians)) let tangentX = -radialY let tangentY = radialX let radialDistance = sqrt(max(radius * radius - halfGap * halfGap, 0)) return CGPoint( x: center.x + radialX * radialDistance + tangentX * halfGap * inwardSign, y: center.y + radialY * radialDistance + tangentY * halfGap * inwardSign ) } private func point(center: CGPoint, radius: CGFloat, degrees: Double) -> CGPoint { let radians = degrees * .pi / 180 return CGPoint( x: center.x + CGFloat(cos(radians)) * radius, y: center.y + CGFloat(sin(radians)) * radius ) } private func degrees(from center: CGPoint, to point: CGPoint) -> Double { atan2(point.y - center.y, point.x - center.x) * 180 / .pi } private func unwrappedEndDegrees(start: Double, end: Double) -> Double { end < start ? end + 360 : end } } @available(iOS 13.0, *) struct RadialMenuDemoView: View { @SwiftUI.State private var itemCount = 8 @SwiftUI.State private var isTouchSelectionEnabled = true static let menuItems = [ RadialMenuSector(title: "Settings Menu".localized, subtitle: "", symbol: "sidebar.left", item: .settings), RadialMenuSector(title: "Host View".localized, subtitle: "", symbol: PublicUtils.liquidGlassEnabled ? "macwindow.on.rectangle" : "tv", item: .hostView), RadialMenuSector(title: "Game Profiles".localized, subtitle: "", symbol: "gamecontroller.circle", item: .gameProfiles), RadialMenuSector(title: "Exit/Disconnect".localized, subtitle: "", symbol: "personalhotspot.slash", item: .gameProfiles), ] private var visibleItems: [RadialMenuSector] { Array(Self.menuItems.prefix(itemCount)) } var body: some View { VStack(spacing: 28) { RadialMenuView( sectors: visibleItems, selectedIndex: isTouchSelectionEnabled ? nil : 2, isTouchSelectionEnabled: isTouchSelectionEnabled ) .frame(width: 320, height: 320) .padding(.top, 24) VStack(spacing: 16) { Stepper("Segments: \(itemCount)", value: $itemCount, in: 3...RadialMenuDemoView.menuItems.count) .frame(maxWidth: 320) Toggle("Touch selection", isOn: $isTouchSelectionEnabled) .frame(maxWidth: 320) } .font(.system(size: 15, weight: .medium)) .padding(.horizontal, 24) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(white: 0.97)) } } @available(iOS 13.0, *) struct RadialMenuView_Previews: PreviewProvider { static var previews: some View { Group { RadialMenuDemoView() .previewLayout(.sizeThatFits) RadialMenuView( sectors: Array(RadialMenuDemoView.menuItems.prefix(6)), selectedIndex: 1, isTouchSelectionEnabled: false ) .frame(width: 300, height: 300) .padding(36) .previewLayout(.sizeThatFits) } } }