Swift and SwiftUI tutorials for Swift Developers

Slider with Ticks in SwiftUI

In the competitive world of app development, user experience (UX) makes the difference between an average app and an excellent one. As an iOS Developer, you constantly face the challenge of creating interfaces that are not only functional but also intuitive and visually appealing. One of the most common interactions is selecting values within a range.

While Apple provides us with excellent native components, sometimes they fall short in visual feedback. The standard Slider is fantastic, but when you need the user to select values in specific increments (for example, 10, 20, 30…), the lack of visual indicators can be confusing.

In this Swift programming tutorial, we will dive deep into how to create a slider with ticks in SwiftUI. This custom component will not only improve the usability of your apps but will also be fully compatible with iOS, macOS, and watchOS, taking full advantage of the power of the Apple ecosystem using Xcode.

1. The Problem with the Standard Slider

In SwiftUI, implementing a slider is as simple as declaring a Slider(value: $myValue, in: 0...100). We can even add a step parameter so the value “jumps” in specific increments:

Slider(value: $volume, in: 0...100, step: 10)

The problem lies in the fact that, although the “thumb” of the slider snaps magnetically to these increments (0, 10, 20, etc.), the user doesn’t see any marks on the track indicating where those stops are. To solve this and take our Swift programming to the next level, we will build a custom, reusable component.

2. Prerequisites

To follow this tutorial, you will need:

  • A Mac with Xcode 14 or higher installed.
  • Basic to intermediate knowledge of Swift and SwiftUI.
  • A desire to create amazing interfaces.

3. Designing our Component: MarkedSlider

Our goal is to create a view in SwiftUI that combines the native behavior of the Slider with a visual layer of ticks evenly distributed along the track.

Step 3.1: Defining the Structure

Open Xcode, create a new SwiftUI App project (make sure it’s cross-platform if you want to test it on Mac and Watch), and create a new file named MarkedSlider.swift.

We’ll start by defining the properties our component needs to be flexible:

import SwiftUI

struct MarkedSlider: View {
    @Binding var value: Double
    let bounds: ClosedRange<Double>
    let step: Double
    let activeColor: Color
    let inactiveColor: Color
    
    // Initializer to provide default values
    init(value: Binding<Double>, 
         in bounds: ClosedRange<Double>, 
         step: Double = 1.0, 
         activeColor: Color = .blue, 
         inactiveColor: Color = .gray.opacity(0.3)) {
        self._value = value
        self.bounds = bounds
        self.step = step
        self.activeColor = activeColor
        self.inactiveColor = inactiveColor
    }
    
    var body: some View {
        // Our view will go here
        Text("Slider under construction")
    }
}

Step 3.2: Calculating the Number of Ticks

To draw the ticks, we need to know how many there are based on the range (bounds) and the increment (step). We will add a computed property to our structure:

private var tickCount: Int {
    let range = bounds.upperBound - bounds.lowerBound
    return Int((range / step).rounded(.down)) + 1
}

Step 3.3: Building the Interface with GeometryReader

The secret to accurately create a slider with ticks in SwiftUI is aligning our visual marks exactly with the native Slider track. To achieve this, we will use GeometryReader, a fundamental tool in SwiftUI that allows us to know the exact size of the container.

Let’s update the body property:

var body: some View {
    GeometryReader { geometry in
        ZStack(alignment: .center) {
            // 1. Base layer: The visual marks (Ticks)
            drawTicks(in: geometry)
            
            // 2. Interactive layer: The native Slider
            Slider(value: $value, in: bounds, step: step)
                .tint(.clear) // Optionally hide the native track, or leave it
        }
    }
    // Give it a fixed height to prevent GeometryReader from collapsing
    .frame(height: 44) 
}

Note for the iOS Developer: The native Slider has internal padding for the thumb. If we try to draw ticks exactly from edge to edge of the GeometryReader, the marks at the ends won’t align with the center of the thumb when it reaches the end. We will address this in the next step by adjusting the drawing.

Step 3.4: Drawing the Ticks

Now, let’s create the drawTicks(in:) function that will iterate over our tickCount and draw small vertical lines.

@ViewBuilder
private func drawTicks(in geometry: GeometryProxy) -> some View {
    // The slider thumb has an approximate width. 
    // We leave horizontal padding to align the ticks with the center of the thumb.
    let horizontalPadding: CGFloat = 14 
    let width = geometry.size.width - (horizontalPadding * 2)
    let tickSpacing = width / CGFloat(tickCount - 1)
    
    ZStack(alignment: .leading) {
        // Draw the background track
        Capsule()
            .fill(inactiveColor)
            .frame(height: 4)
            .padding(.horizontal, horizontalPadding)
        
        // Draw the active track (Progress)
        Capsule()
            .fill(activeColor)
            .frame(width: activeTrackWidth(totalWidth: width) + horizontalPadding * 2, height: 4)
            .padding(.horizontal, horizontalPadding)
        
        // Draw the ticks
        ForEach(0..<tickCount, id: \.self) { index in
            let isPast = isTickPast(index: index)
            
            Capsule()
                .fill(isPast ? activeColor : Color.gray)
                .frame(width: 2, height: 12)
                .offset(x: horizontalPadding + (CGFloat(index) * tickSpacing) - 1)
        }
    }
}

Step 3.5: Progress Logic and Tick Color

As you can see in the previous code, we call two helper functions: activeTrackWidth and isTickPast. These functions allow the progress to be filled with the active color and the ticks to change color once the thumb passes them.

Add these functions to your MarkedSlider structure:

// Calculates the width of the progress bar
private func activeTrackWidth(totalWidth: CGFloat) -> CGFloat {
    let range = bounds.upperBound - bounds.lowerBound
    let currentValue = value - bounds.lowerBound
    let percentage = currentValue / range
    return totalWidth * CGFloat(percentage)
}

// Determines if a specific tick has already been surpassed by the current value
private func isTickPast(index: Int) -> Bool {
    let tickValue = bounds.lowerBound + (Double(index) * step)
    return value >= tickValue
}

Step 3.6: Assembling the Final View and Hiding the Native Slider

For our custom design to shine, we must overlay the native Slider making it invisible, so it only acts as the controller for the touch logic, while the custom graphics do the visual work.

Let’s modify the body to achieve the perfect effect:

var body: some View {
    GeometryReader { geometry in
        ZStack(alignment: .center) {
            // Our Custom UI
            drawTicks(in: geometry)
            
            // Invisible slider on top to capture gestures
            Slider(value: $value, in: bounds, step: step)
                .opacity(0.01) // Almost invisible, but interactive
        }
    }
    .frame(height: 44)
}

Swift programming expert trick: We use .opacity(0.01) instead of .opacity(0) or .hidden(). If you set opacity to zero or hide it, SwiftUI disables touch interaction on that element. With 1%, it’s invisible to the human eye, but the iOS system still registers user touches.


4. Cross-Platform Implementation: iOS, macOS, and watchOS

One of the marvels of Swift and SwiftUI is its “Learn once, apply anywhere” philosophy. The code we just wrote is inherently cross-platform.

Considerations for watchOS

On the Apple Watch, space is extremely limited. When trying to create a slider with ticks in SwiftUI for watchOS, fine touch interaction is difficult. Fortunately, by using the underlying native Slider invisibly, the Digital Crown will automatically control our MarkedSlider smoothly, respecting the step we configured. Pure magic in Xcode!

Considerations for macOS

On a Mac, the user will interact with a mouse or trackpad. The click behavior on the slider track will jump directly to the nearest mark, offering a native and predictable experience for desktop users.


5. Accessibility (VoiceOver)

Any senior iOS Developer knows that an app is not finished until it is accessible. By visually hiding the native slider and drawing our own interface, we could be harming accessibility if we are not careful.

By default, leaving the Slider with .opacity(0.01), VoiceOver can still read it. However, to ensure we offer the best experience, we can apply explicit accessibility modifiers to our main container:

var body: some View {
    GeometryReader { geometry in
        // ... (ZStack code)
    }
    .frame(height: 44)
    .accessibilityElement(children: .ignore)
    .accessibilityLabel("Level control")
    .accessibilityValue("\(Int(value))")
    .accessibilityAdjustableAction { direction in
        switch direction {
        case .increment:
            if value < bounds.upperBound { value += step }
        case .decrement:
            if value > bounds.lowerBound { value -= step }
        @unknown default:
            break
        }
    }
}

This block ensures that visually impaired users can interact with our slider using standard VoiceOver increment/decrement gestures.


6. Testing the MarkedSlider

Now that we have our component ready, let’s use it in our main ContentView view.

struct ContentView: View {
    @State private var brightness: Double = 50
    @State private var volume: Double = 0
    
    var body: some View {
        VStack(spacing: 40) {
            Text("System Settings")
                .font(.largeTitle)
                .bold()
            
            VStack(alignment: .leading, spacing: 10) {
                Text("Brightness: \(Int(brightness))%")
                    .font(.headline)
                
                // Using our component
                MarkedSlider(value: $brightness, in: 0...100, step: 25)
                
                HStack {
                    Text("0%")
                    Spacer()
                    Text("100%")
                }
                .font(.caption)
                .foregroundColor(.secondary)
            }
            .padding()
            
            VStack(alignment: .leading, spacing: 10) {
                Text("Phased Volume")
                    .font(.headline)
                
                // Another example with different colors and ranges
                MarkedSlider(
                    value: $volume, 
                    in: 0...10, 
                    step: 1, 
                    activeColor: .green, 
                    inactiveColor: .green.opacity(0.2)
                )
            }
            .padding()
            
            Spacer()
        }
        .padding()
    }
}

Analyzing the Result

When you compile this in Xcode, you will see two beautiful sliders. The first one (Brightness) will have 5 ticks (0, 25, 50, 75, 100). The second one (Volume) will have 11 ticks (from 0 to 10). By sliding your finger (or the mouse on macOS), you’ll notice how the value jumps from tick to tick, and the progress color fills dynamically, changing the color of the ticks as it passes them.


7. Performance and Architecture Tips

For an advanced iOS Developer, it’s crucial to understand the impact of what we program:

  1. Avoid over-calculation in GeometryReader: GeometryReader can cause the view to recalculate if the container sizes change. Since we’ve given it a strict .frame(height: 44), we mitigate layout issues that often plague complex interfaces in SwiftUI.
  2. Use of @ViewBuilder: By extracting the drawing logic to the drawTicks function, we use @ViewBuilder. This keeps the view’s body clean, readable, and allows the Swift compiler to better optimize the view tree.
  3. State vs. Binding: Notice how we use @Binding for the value. This ensures that the MarkedSlider doesn’t own its data, but simply acts as a controller for the state living in its parent view (ContentView). This is the core of declarative architecture.

Conclusion

Knowing how to create a slider with ticks in SwiftUI demonstrates a deep understanding of how to manipulate views, safely leverage GeometryReader, and improve user experience (UX) beyond the standard components provided by the SDK.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

How to show NavigationLink as a Button in SwiftUI

Related Posts