·6 min read

Exploring SwiftUI: Creating a Custom Slider Inspired By Camera Control

44% OFF - Black Friday Sale

Use the coupon "BLACKFRIDAY" at checkout page for AI Driven Coding, Foundation Models MLX, MusicKit, freelancing or technical writing.

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)
    }
  }
}

What's Next

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!

44% OFF - Black Friday Sale

Use the coupon "BLACKFRIDAY" at checkout page for AI Driven Coding, Foundation Models MLX, MusicKit, freelancing or technical writing.

Post Topics

Explore more in these categories:

Related Articles

Exploring Stream's Video SDK: Creating a WWDC Watch Party App

Build a WWDC 2024 Watch Party App using Stream's Video SDK. Implement video playback, calling features, and synchronize playback for seamless group viewing. Dive into Stream's powerful tools to create interactive experiences for Apple developers and elevate your WWDC experience.

Exploring AI: Cosine Similarity for RAG using Accelerate and Swift

Learn how to implement cosine similarity using Accelerate framework for iOS and macOS apps. Build Retrieval-Augmented Generation (RAG) systems breaking down complex mathematics into simple explanations and practical Swift code examples. Optimize document search with vector similarity calculations.