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.

0:00
/0:10

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!