radial menu demo
This commit is contained in:
@@ -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"
|
||||
@@ -5,7 +5,7 @@ import SwiftUI
|
||||
struct VoidLinkPreview: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
WidgetPickerView()
|
||||
RadialMenuDemoView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user