Exploring SwiftUI: Creating a Custom Slider Inspired By Camera Control
When I first saw the smooth animation of the Camera Control zooming in and out in iPhone 16, I was intrigued . Naturally, I wanted something like that for my own app, Meshing (coming soon to the App Store day 1 on iOS 18 and iPadOS 18!).
Pulling off that animation was too difficult. I got a snippet, and made a slow motion out of it.
I swear, you have no idea how many times I watched it again and again.
I realized that I might not be able to fully replicate the camera control effect, but I could create something with a similar feel that would fit into Meshing.
The MeshingSlider
I started with a MeshingSlider
struct. This is the main component of the slider.
struct MeshingSlider: View {
@Binding var value: CGFloat
let range: ClosedRange<Double>
let stepCount: Int
let colors: [Color]
@State private var isDragging = false
@State private var lastValue: Double
// ... init and body
}
The MeshingSlider
takes a few key parameters:
value
: Current value of the slider.range
: The range of values the slider can represent.stepCount
: The number of steps or segments in the slider.colors
: An array of colors for the gradient effect.
I also added two state variables:
isDragging
: Keeps track of whether the user is currently interacting with the slider.lastValue
: Stores the previous value, which helps trigger haptic feedback.
The Slider's Body
The body of the slider is where it calculates the height and highlight for the animations:
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .bottom) {
// Background track
Rectangle()
.fill(Color.adaptive)
.frame(height: 15)
// Bar indicators
HStack(alignment: .bottom, spacing: 6) {
ForEach(0..<stepCount, id: \.self) { index in
BarIndicator(
height: self.getBarHeight(for: index),
isHighlighted: Double(index) <= self.getNormalizedValue() * Double(stepCount - 1),
isCurrentValue: self.isCurrentValue(index),
isDragging: isDragging,
shouldShow: Double(index) <= self.getNormalizedValue() * Double(stepCount - 1),
colors: colors
)
}
}
}
// ... gesture and frame
}
}
I used a GeometryReader
to get the size of the view. Inside, there is a ZStack
with two main parts:
- A background track: Just a simple rectangle.
- The bar indicators: These are the vertical bars that make up the slider. Each one is a
BarIndicator
view.
The BarIndicator
Each bar in the slider is represented by a BarIndicator
:
struct BarIndicator: View {
let height: CGFloat
let isHighlighted: Bool
let isCurrentValue: Bool
let isDragging: Bool
let shouldShow: Bool
let colors: [Color]
var body: some View {
RoundedRectangle(cornerRadius: 4)
.fill(/* ... color logic ... */)
.frame(width: 4, height: (isDragging && shouldShow) ? height : 15)
.animation(.bouncy, value: height)
.animation(.bouncy, value: isDragging)
.animation(.bouncy, value: shouldShow)
}
}
The BarIndicator
changes color and height based on its state, creating that smooth, wave-like effect when you drag the slider.
Handling User Input
To make the slider interactive, I added a drag gesture:
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { gesture in
let newValue = self.getValue(geometry: geometry, dragLocation: gesture.location)
self.value = min(max(self.range.lowerBound, newValue), self.range.upperBound)
isDragging = true
// Trigger haptic feedback when moving between steps
if Int(self.value) != Int(self.lastValue) {
HapticManager.shared.trigger(.light)
self.lastValue = self.value
}
}
.onEnded { _ in
isDragging = false
HapticManager.shared.trigger(.light)
}
)
This gesture does a few things:
- Updates the slider's value based on where the user is dragging.
- Triggers haptic feedback when moving between steps.
- Updates the
isDragging
state.
Container with MeshingSliderView
To make the slider easy to use in my app, I wrapped it in a MeshingSliderView
view:
struct MeshingSlider: View {
@Binding var value: CGFloat
let colors: [Color]
var range: ClosedRange<Double> = 0...35
var body: some View {
HStack(alignment: .center) {
CustomSlider(value: $value.animation(.bouncy), range: range, stepCount: 35, colors: colors)
}
}
}
This gives me a custom slider with a bouncy animation effect.
Full Code Example
Here is the entire code snippet to play around with:
import SwiftUI
struct CustomSlider: View {
@Binding var value: CGFloat
let range: ClosedRange<Double>
let stepCount: Int
let colors: [Color]
@State private var isDragging = false
@State private var lastValue: Double
init(value: Binding<CGFloat>, range: ClosedRange<Double>, stepCount: Int, colors: [Color]) {
self._value = value
self.colors = colors
self.range = range
self.stepCount = stepCount
self._lastValue = State(initialValue: value.wrappedValue)
}
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .bottom) {
// Background track
Rectangle()
.fill(Color.adaptive)
.frame(height: 15)
// Bar indicators
HStack(alignment: .bottom, spacing: 6) {
ForEach(0..<stepCount, id: \.self) { index in
BarIndicator(
height: self.getBarHeight(for: index),
isHighlighted: Double(index) <= self.getNormalizedValue() * Double(stepCount - 1),
isCurrentValue: self.isCurrentValue(index),
isDragging: isDragging,
shouldShow: Double(index) <= self.getNormalizedValue() * Double(stepCount - 1), colors: colors
)
}
}
}
.frame(minHeight: 50, alignment: .bottom)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { gesture in
let newValue = self.getValue(geometry: geometry, dragLocation: gesture.location)
self.value = min(max(self.range.lowerBound, newValue), self.range.upperBound)
isDragging = true
// Trigger haptic feedback when moving between steps
if Int(self.value) != Int(self.lastValue) {
HapticManager.shared.trigger(.light)
self.lastValue = self.value
}
}
.onEnded { _ in
isDragging = false
HapticManager.shared.trigger(.light)
}
)
}
}
private func getProgress(geometry: GeometryProxy) -> CGFloat {
let percent = (self.value - self.range.lowerBound) / (self.range.upperBound - self.range.lowerBound)
return geometry.size.width * CGFloat(percent)
}
private func getValue(geometry: GeometryProxy, dragLocation: CGPoint) -> Double {
let percent = Double(dragLocation.x / geometry.size.width)
let value = percent * (self.range.upperBound - self.range.lowerBound) + self.range.lowerBound
return value
}
private func getNormalizedValue() -> Double {
return (self.value - self.range.lowerBound) / (self.range.upperBound - self.range.lowerBound)
}
private func getBarHeight(for index: Int) -> CGFloat {
let normalizedValue = self.getNormalizedValue()
let stepValue = Double(index) / Double(stepCount - 1)
let difference = abs(normalizedValue - stepValue)
let maxHeight: CGFloat = 35
let minHeight: CGFloat = 15
if difference < 0.15 {
return maxHeight - CGFloat(difference / 0.15) * (maxHeight - minHeight)
} else {
return minHeight
}
}
private func isCurrentValue(_ index: Int) -> Bool {
let normalizedValue = self.getNormalizedValue()
let stepValue = Double(index) / Double(stepCount - 1)
return abs(normalizedValue - stepValue) < (1.0 / Double(stepCount - 1)) / 2
}
}
struct BarIndicator: View {
let height: CGFloat
let isHighlighted: Bool
let isCurrentValue: Bool
let isDragging: Bool
let shouldShow: Bool
let colors: [Color]
var body: some View {
RoundedRectangle(cornerRadius: 4)
.fill(isCurrentValue ? LinearGradient(colors: colors, startPoint: .bottom, endPoint: .top) : (isHighlighted ? LinearGradient(colors: colors.map { $0.opacity(0.75) }, startPoint: .bottom, endPoint: .top) : LinearGradient(colors: [.primary.opacity(0.4), .primary.opacity(0.3)], startPoint: .bottom, endPoint: .top)))
.frame(width: 4, height: (isDragging && shouldShow) ? height : 15)
.animation(.bouncy, value: height)
.animation(.bouncy, value: isDragging)
.animation(.bouncy, value: shouldShow)
}
}
struct MeshingSlider: View {
@Binding var value: CGFloat
let colors: [Color]
var range: ClosedRange<Double> = 0...35
var body: some View {
HStack(alignment: .center) {
CustomSlider(value: $value.animation(.bouncy), range: range, stepCount: 35, colors: colors)
}
}
}
Moving Forward
It is not an exact replica of what I envisioned, but with the haptics, it captures what I was going for: an ASMR fidget toy that also acts as a slider. Plus, it fits perfectly with the Meshing aesthetic.
One thing is that the bar moves linearly while the Camera Control moves exponentially, something that I could not replicate. If you can, let me know!
Happy sliding!