Long Press Radial Menu
A long press opens a ring of actions, and dragging onto one of them selects it on release.

- Gestures
- iOS 18.0+
- intermediate
- Origin
- Original work
- Found by
- long press · radial menu · pie menu · ring · drag · actions · context menu · pulsación larga · menú radial · menú circular · anillo · arrastrar · acciones · menú contextual
Description
Section titled “Description”A long press opens a ring of actions. Drag onto one and let go to choose it.
VoiceOver offers the same actions in its Actions list. With Reduce Motion on, the ring fades in place.
struct LongPressRadialMenuDemo: View { @State private var lastAction = "Hold, then drag"
private var items: [LongPressRadialMenuItem] { [ LongPressRadialMenuItem(id: "camera", title: "Camera", systemImage: "camera.fill") { lastAction = "Camera" }, LongPressRadialMenuItem(id: "note", title: "Note", systemImage: "note.text") { lastAction = "Note" }, LongPressRadialMenuItem(id: "share", title: "Share", systemImage: "square.and.arrow.up") { lastAction = "Share" }, LongPressRadialMenuItem(id: "star", title: "Favorite", systemImage: "star.fill") { lastAction = "Favorite" }, LongPressRadialMenuItem(id: "trash", title: "Delete", systemImage: "trash.fill") { lastAction = "Delete" }, ] }
var body: some View { ZStack { LinearGradient(colors: [.indigo, .purple, .pink], startPoint: .top, endPoint: .bottom) .ignoresSafeArea()
VStack { Text(lastAction) .font(.largeTitle.bold()) .foregroundStyle(.white) .padding(.top, 48) Spacer() }
LongPressRadialMenu(items: items) { Image(systemName: "plus") .font(.title.bold()) .foregroundStyle(.indigo) .frame(width: 84, height: 84) .background(.white, in: .circle) .shadow(color: .black.opacity(0.25), radius: 14, y: 8) } } }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
items | [LongPressRadialMenuItem] | Required |
radius | CGFloat | 104 |
minimumDuration | Double | 0.35 |
tint | Color | .indigo |
accessibilityTitle | String | "Actions" |
label | () -> Label | Required |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
struct LongPressRadialMenuItem: Identifiable { let id: String let title: LocalizedStringResource let systemImage: String let action: () -> Void}
/// A long press opens a ring of actions. Drag onto one and let go to choose it.////// VoiceOver offers the same actions in its Actions list. With Reduce Motion on,/// the ring fades in place.struct LongPressRadialMenu<Label: View>: View { let items: [LongPressRadialMenuItem] var radius: CGFloat = 104 var minimumDuration = 0.35 var tint: Color = .indigo var accessibilityTitle = "Actions" @ViewBuilder var label: () -> Label
private enum Press: Equatable { case idle case pressing case open(CGSize) }
@GestureState private var press = Press.idle @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.layoutDirection) private var layoutDirection
private var isOpen: Bool { if case .open = press { return true } return false }
private var hoveredIndex: Int? { guard case .open(let translation) = press else { return nil } return LongPressRadialMenuMath.index(translation: translation, count: items.count, radius: radius) }
var body: some View { label() .scaleEffect(press == .idle || reduceMotion ? 1 : 0.9) .animation(reduceMotion ? nil : .spring(duration: 0.3, bounce: 0.3), value: press == .idle) .contentShape(.rect) .overlay { ring.allowsHitTesting(false).accessibilityHidden(true) } .gesture(gesture) .sensoryFeedback(.impact(weight: .medium), trigger: isOpen) { _, nowOpen in nowOpen } .sensoryFeedback(.selection, trigger: hoveredIndex) .accessibilityElement(children: .combine) .accessibilityLabel(accessibilityTitle) .accessibilityActions { ForEach(items) { item in Button(item.title, action: item.action) } } }
private var ring: some View { ZStack { LongPressRadialMenuBubbles(items: items, radius: radius, tint: tint, isOpen: isOpen, hoveredIndex: hoveredIndex) // The bubbles sit on a fixed radius, so they read their size under this cap. The title below does not. .dynamicTypeSize(...DynamicTypeSize.xxxLarge)
if let hoveredIndex, items.indices.contains(hoveredIndex) { // Its bottom edge clears the largest bubble, so a taller title grows away from the ring. // The overlay offers it the width of the label, which would break a word in two. Text(items[hoveredIndex].title) .font(.headline) .lineLimit(1) .fixedSize() .padding(.horizontal, 14) .padding(.vertical, 8) .background(.regularMaterial, in: .capsule) .alignmentGuide(VerticalAlignment.center) { $0[.bottom] } .offset(y: -(radius + 48)) } } }
private var gesture: some Gesture { LongPressGesture(minimumDuration: max(minimumDuration, 0)) .sequenced(before: DragGesture(minimumDistance: 0)) .updating($press) { value, state, _ in switch value { case .first(true): state = .pressing case .second(true, let drag): state = .open(layoutTranslation(drag?.translation ?? .zero)) default: state = .idle } } .onEnded { value in guard case .second(true, let drag?) = value, let index = LongPressRadialMenuMath.index(translation: layoutTranslation(drag.translation), count: items.count, radius: radius), items.indices.contains(index) else { return } items[index].action() } }
/// Gestures report screen points, but layout mirrors in right-to-left layouts. private func layoutTranslation(_ translation: CGSize) -> CGSize { layoutDirection == .rightToLeft ? CGSize(width: -translation.width, height: translation.height) : translation }}
private struct LongPressRadialMenuBubbles: View { let items: [LongPressRadialMenuItem] let radius: CGFloat let tint: Color let isOpen: Bool let hoveredIndex: Int?
@ScaledMetric(relativeTo: .title3) private var bubbleSize = 56.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View { ForEach(Array(items.enumerated()), id: \.element.id) { index, item in let isHovered = hoveredIndex == index let offset = LongPressRadialMenuMath.offset(index: index, count: items.count, radius: radius)
Image(systemName: item.systemImage) .font(.title3.weight(.semibold)) .foregroundStyle(isHovered ? .white : tint) .frame(width: bubbleSize, height: bubbleSize) .background(isHovered ? AnyShapeStyle(tint) : AnyShapeStyle(.regularMaterial), in: .circle) .scaleEffect(reduceMotion ? 1 : (isOpen ? (isHovered ? 1.2 : 1) : 0.3)) .offset(isOpen || reduceMotion ? offset : .zero) .opacity(isOpen ? 1 : 0) .animation( reduceMotion ? .easeInOut(duration: 0.15) : .spring(duration: 0.35, bounce: 0.4).delay(isOpen ? Double(index) * 0.02 : 0), value: isOpen ) .animation(reduceMotion ? nil : .spring(duration: 0.2), value: isHovered) } }}
enum LongPressRadialMenuMath { /// How far the finger must travel, as a share of the radius, to reach the ring. static let deadZone = 0.45
/// The direction of an item in radians, clockwise from the top. static func angle(index: Int, count: Int) -> Double { guard count > 0 else { return 0 } return 2 * .pi * Double(index) / Double(count) }
static func offset(index: Int, count: Int, radius: CGFloat) -> CGSize { let angle = angle(index: index, count: count) return CGSize(width: sin(angle) * radius, height: -cos(angle) * radius) }
/// The item nearest the finger, or `nil` while the finger is still near the center. static func index(translation: CGSize, count: Int, radius: CGFloat) -> Int? { guard count > 0, radius > 0 else { return nil } guard hypot(translation.width, translation.height) >= radius * deadZone else { return nil }
let clockwise = atan2(translation.width, -translation.height) let positive = clockwise < 0 ? clockwise + 2 * .pi : clockwise let step = 2 * .pi / Double(count) return Int((positive / step).rounded()) % count }}
// MARK: - Demo
struct LongPressRadialMenuDemo: View { @State private var lastAction = "Hold, then drag"
private var items: [LongPressRadialMenuItem] { [ LongPressRadialMenuItem(id: "camera", title: "Camera", systemImage: "camera.fill") { lastAction = "Camera" }, LongPressRadialMenuItem(id: "note", title: "Note", systemImage: "note.text") { lastAction = "Note" }, LongPressRadialMenuItem(id: "share", title: "Share", systemImage: "square.and.arrow.up") { lastAction = "Share" }, LongPressRadialMenuItem(id: "star", title: "Favorite", systemImage: "star.fill") { lastAction = "Favorite" }, LongPressRadialMenuItem(id: "trash", title: "Delete", systemImage: "trash.fill") { lastAction = "Delete" }, ] }
var body: some View { ZStack { LinearGradient(colors: [.indigo, .purple, .pink], startPoint: .top, endPoint: .bottom) .ignoresSafeArea()
VStack { Text(lastAction) .font(.largeTitle.bold()) .foregroundStyle(.white) .padding(.top, 48) Spacer() }
LongPressRadialMenu(items: items) { Image(systemName: "plus") .font(.title.bold()) .foregroundStyle(.indigo) .frame(width: 84, height: 84) .background(.white, in: .circle) .shadow(color: .black.opacity(0.25), radius: 14, y: 8) } } }}
#Preview { LongPressRadialMenuDemo()}