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!

Astro Affiliate.

Astro (Affiliate)

Find the right keywords for your app and climb the App Store rankings. Improve your app visibility and increase your revenue with Astro. The first App Store Optimization tool designed for indie developers!

Tagged in: