radial menu demo

This commit is contained in:
True Zhuanjia
2026-07-01 15:20:57 +08:00
parent 41a5855211
commit 54c106e01e
26 changed files with 1027 additions and 476 deletions
+433
View File
@@ -0,0 +1,433 @@
//
// 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)
}
}
}
@@ -0,0 +1,6 @@
//
// VoidLinkPreview-Bridging-Header.h
// VoidLinkPreview
//
#import "../VoidLink/Localization/LocalizationHelper.h"
+1 -1
View File
@@ -5,7 +5,7 @@ import SwiftUI
struct VoidLinkPreview: App {
var body: some Scene {
WindowGroup {
WidgetPickerView()
RadialMenuDemoView()
}
}
}