434 lines
17 KiB
Swift
434 lines
17 KiB
Swift
//
|
|
// RadialMenuView.swift
|
|
// VoidLink
|
|
//
|
|
// Created by True砖家 on 2026/6/29.
|
|
// Copyright © 2026 True砖家 @ Bilibili. All rights reserved.
|
|
//
|
|
|
|
import SwiftUI
|
|
import Combine
|
|
import UIKit
|
|
|
|
@available(iOS 13.0, *)
|
|
struct RadialMenuItem: Identifiable, Equatable {
|
|
let id = UUID()
|
|
let title: String
|
|
let subtitle: String
|
|
let systemImageName: String
|
|
|
|
init(title: String, subtitle: String, systemImageName: String) {
|
|
self.title = title
|
|
self.subtitle = subtitle
|
|
self.systemImageName = systemImageName
|
|
}
|
|
}
|
|
|
|
@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.white
|
|
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.16
|
|
var segmentIconScale: CGFloat = 0.085
|
|
}
|
|
|
|
@available(iOS 13.0, *)
|
|
final class RadialMenuSelectionState: ObservableObject {
|
|
@Published private(set) var selectedIndex: Int?
|
|
@Published private(set) var hasReceivedJoystickInput = false
|
|
|
|
private var itemCount = 0
|
|
|
|
func updateSelection(xOffset: Float, yOffset: Float) {
|
|
updateSelection(xOffset: xOffset, yOffset: yOffset, deadZone: 0.1)
|
|
}
|
|
|
|
func updateSelection(xOffset: Float, yOffset: Float, deadZone: Float) {
|
|
let nextSelectedIndex = Self.selectedIndex(
|
|
xOffset: xOffset,
|
|
yOffset: yOffset,
|
|
itemCount: itemCount,
|
|
deadZone: deadZone
|
|
)
|
|
|
|
guard !hasReceivedJoystickInput || nextSelectedIndex != selectedIndex else {
|
|
return
|
|
}
|
|
|
|
hasReceivedJoystickInput = true
|
|
selectedIndex = nextSelectedIndex
|
|
}
|
|
|
|
fileprivate func configureItemCount(_ itemCount: Int) {
|
|
self.itemCount = max(itemCount, 0)
|
|
if let selectedIndex, selectedIndex >= itemCount {
|
|
self.selectedIndex = nil
|
|
}
|
|
}
|
|
|
|
static func selectedIndex(xOffset: Float, yOffset: Float, itemCount: Int, deadZone: Float = 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 clockwiseDegreesFromTop = positiveRemainder(atan2(Double(x), Double(y)) * 180 / .pi, 360)
|
|
let segmentDegrees = 360 / Double(itemCount)
|
|
let centeredDegrees = positiveRemainder(clockwiseDegreesFromTop + 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
|
|
}
|
|
}
|
|
|
|
@available(iOS 13.0, *)
|
|
struct RadialMenuView: View {
|
|
let items: [RadialMenuItem]
|
|
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: RadialMenuItem? {
|
|
guard let selectedIndex = effectiveSelectedIndex,
|
|
items.indices.contains(selectedIndex) else {
|
|
return items.first
|
|
}
|
|
return items[selectedIndex]
|
|
}
|
|
|
|
init(
|
|
items: [RadialMenuItem],
|
|
selectedIndex: Int? = nil,
|
|
isTouchSelectionEnabled: Bool = true,
|
|
style: RadialMenuStyle = RadialMenuStyle(),
|
|
selectionState: RadialMenuSelectionState = RadialMenuSelectionState()
|
|
) {
|
|
self.items = items
|
|
self.selectedIndex = selectedIndex
|
|
self.isTouchSelectionEnabled = isTouchSelectionEnabled
|
|
self.style = style
|
|
self.joystickSelectionState = selectionState
|
|
self.joystickSelectionState.configureItemCount(items.count)
|
|
}
|
|
|
|
func updateSelection(xOffset: Float, yOffset: Float) {
|
|
joystickSelectionState.configureItemCount(items.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(items.indices, id: \.self) { index in
|
|
RadialMenuSegmentShape(
|
|
index: index,
|
|
count: items.count,
|
|
innerRadiusRatio: 1 - style.ringWidthRatio,
|
|
gapWidth: style.segmentGapWidth
|
|
)
|
|
.fill(index == effectiveSelectedIndex ? style.selectedRingColor : style.ringColor)
|
|
.overlay(
|
|
RadialMenuSegmentShape(
|
|
index: index,
|
|
count: items.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(items.indices, id: \.self) { index in
|
|
let item = items[index]
|
|
Image(systemName: item.systemImageName)
|
|
.font(.system(size: max(diameter * style.segmentIconScale, 18), weight: .medium))
|
|
.foregroundColor(index == effectiveSelectedIndex ? style.selectedIconColor : style.iconColor)
|
|
.scaleEffect(index == effectiveSelectedIndex ? 1.18 : 1)
|
|
.position(iconPosition(index: index, count: items.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: 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 !items.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 rawDegrees = atan2(dy, dx) * 180 / .pi
|
|
let clockwiseDegreesFromTop = positiveRemainder(rawDegrees + 450, 360)
|
|
let segmentDegrees = 360 / Double(items.count)
|
|
let centeredDegrees = positiveRemainder(clockwiseDegreesFromTop + segmentDegrees / 2, 360)
|
|
return min(Int(centeredDegrees / segmentDegrees), items.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 = -90 + 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: RadialMenuItem?
|
|
let style: RadialMenuStyle
|
|
let diameter: CGFloat
|
|
|
|
var body: some View {
|
|
VStack(spacing: max(diameter * 0.018, 4)) {
|
|
Image(systemName: item?.systemImageName ?? "circle.grid.cross")
|
|
.font(.system(size: max(diameter * style.centerIconScale, 28), weight: .medium))
|
|
.foregroundColor(style.iconColor)
|
|
.id(item?.id)
|
|
.transition(.opacity)
|
|
|
|
Text(item?.title ?? "")
|
|
.font(.system(size: max(diameter * 0.047, 13), weight: .medium))
|
|
.foregroundColor(style.titleColor)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.62)
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
@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 = -90 + 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 = [
|
|
RadialMenuItem(title: "Settings Menu".localized, subtitle: "", systemImageName: "sidebar.left"),
|
|
RadialMenuItem(title: "Host View".localized, subtitle: "", systemImageName: PublicUtils.liquidGlassEnabled ? "macwindow.on.rectangle" : "tv"),
|
|
RadialMenuItem(title: "Game Profiles".localized, subtitle: "", systemImageName: "gamecontroller.circle"),
|
|
]
|
|
|
|
private var visibleItems: [RadialMenuItem] {
|
|
Array(Self.menuItems.prefix(itemCount))
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 28) {
|
|
RadialMenuView(
|
|
items: 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(
|
|
items: Array(RadialMenuDemoView.menuItems.prefix(6)),
|
|
selectedIndex: 1,
|
|
isTouchSelectionEnabled: false
|
|
)
|
|
.frame(width: 300, height: 300)
|
|
.padding(36)
|
|
.previewLayout(.sizeThatFits)
|
|
}
|
|
}
|
|
}
|