Skip to content

Instantly share code, notes, and snippets.

@jrsaruo
Last active February 24, 2025 06:31
Show Gist options
  • Save jrsaruo/a1420744527a2d34c8c0ce07ca8b72d5 to your computer and use it in GitHub Desktop.
Save jrsaruo/a1420744527a2d34c8c0ce07ca8b72d5 to your computer and use it in GitHub Desktop.
Horizontal Inline Picker by SwiftUI
import SwiftUI
// https://x.com/jrsaruo_tech/status/1893585977760743750
@available(iOS 18, *)
struct HorizontalInlinePicker<SelectionValue, Content>: View where SelectionValue: Hashable, Content: View {
@Binding var selection: SelectionValue
@State private var centerValue: SelectionValue?
@State private var pickerWidth: CGFloat = 0
@Environment(\.displayScale) var displayScale
/// The number of options displayed in the picker.
let maxVisibleElementCount: Int
let content: Content
private var elementWidth: CGFloat {
pickerWidth / CGFloat(maxVisibleElementCount)
}
private var pickerCoordinateSpace: NamedCoordinateSpace {
.named("\(HorizontalInlinePicker.self).\(#function)")
}
init(
selection: Binding<SelectionValue>,
maxVisibleElementCount: Int,
@ViewBuilder content: () -> Content
) {
self._selection = selection
self.maxVisibleElementCount = maxVisibleElementCount
self.content = content()
}
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) {
ForEach(subviews: content) { subview in
let value = subview.containerValues.tag(
for: SelectionValue.self
)
Button {
if let value {
centerValue = value
}
} label: {
subview
.padding(.vertical, 8)
.frame(width: elementWidth)
.multilineTextAlignment(.center)
}
.id(value)
.buttonStyle(.plain)
.font(.title)
.visualEffect { effect, proxy in
MainActor.assumeIsolated {
let frame = proxy.frame(
in: pickerCoordinateSpace
)
let isVisible = switch frame.midX {
case 0...pickerWidth: true
default: false
}
// Project onto a cylinder
let projected = (
minX: projectedX(from: frame.minX),
maxX: projectedX(from: frame.maxX)
)
let projectedWidth = projected.maxX - projected.minX
let scaleX = isVisible
? projectedWidth / frame.width
: 0.0
let offsetX = projected.minX - frame.minX
return effect
.scaleEffect(
x: scaleX,
anchor: .leading
)
.offset(x: offsetX)
.opacity(scaleX)
}
}
}
}
.scrollTargetLayout()
}
.coordinateSpace(pickerCoordinateSpace)
.onGeometryChange(for: CGFloat.self) { proxy in
proxy.size.width
} action: { newValue in
pickerWidth = newValue
}
// Allow the elements at the horizontal edges to scroll to the center
.contentMargins(
.horizontal,
(pickerWidth - elementWidth) / 2,
for: .scrollContent
)
.scrollTargetBehavior(.viewAligned)
.scrollPosition(id: $centerValue, anchor: .center)
.onScrollPhaseChange { _, newPhase in
if !newPhase.isScrolling, let centerValue {
selection = centerValue
}
}
.background {
selectionBackground
}
.mask {
selectionBackground
.background(.white.opacity(0.4))
}
.onAppear {
// Scroll to the initial selection position
withTransaction(\.disablesAnimations, true) {
centerValue = selection
}
}
.onChange(of: selection) { _, newValue in
centerValue = selection
}
.sensoryFeedback(.selection, trigger: centerValue)
.animation(.default, value: centerValue)
}
private func projectedX(from x: CGFloat) -> CGFloat {
let cylinderRadius = pickerWidth / .pi
let theta = x * .pi / pickerWidth
return pickerWidth / 2 - cylinderRadius * cos(theta)
}
@ViewBuilder
private var selectionBackground: some View {
HStack {
Spacer()
ContainerRelativeShape()
.foregroundStyle(.background.secondary)
.frame(width: elementWidth)
.overlay {
ContainerRelativeShape()
.strokeBorder(lineWidth: 1 / displayScale)
.foregroundStyle(.separator)
}
.containerShape(.rect(cornerRadius: 12))
Spacer()
}
}
}
@available(iOS 18, *)
#Preview {
@Previewable @State var selection = 0
VStack(spacing: 16) {
HorizontalInlinePicker(
selection: $selection,
maxVisibleElementCount: 8
) {
ForEach(-15...15, id: \.self) { value in
Text("\(value)")
}
}
Text("Selected: \(selection)")
Button("Reset") {
selection = 0
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment