Create a circular slider in SwiftUI

A Slider control is a UI control that allows the user to select a value from within a range of values. In SwiftUI, it is usually presented as a thumb selector on a linear line. There are times when it might be better to present this type of selector as a circle with the thumb moving around the circumference. This article describes how to define a circular slider in SwiftUIt.

More information on the default Slider is covered in How to customise the Slider in SwiftUI as well as options to customise the look and feel.

[April 2023]
See related article on how to Create a circular slider in SwiftUI with ChatGPT.



Initial circular outline

Start with three circles in a ZStack. A gray circle to represent the outline of path of the Slider, a pale red arc to represent the progress along the circle and a circle to represent the current cursor or thumb position. Set the range of the slider from 0.0 to 1.0 and hard-code in a diameter as well as a current position progress of 0.33.

 1struct CircularSliderView1: View {
 2    let progress = 0.33
 3    let ringDiameter = 300.0
 4    
 5    private var rotationAngle: Angle {
 6        return Angle(degrees: (360.0 * progress))
 7    }
 8    
 9    var body: some View {
10        VStack {
11            ZStack {
12                Circle()
13                    .stroke(Color(hue: 0.0, saturation: 0.0, brightness: 0.9), lineWidth: 20.0)
14                Circle()
15                    .trim(from: 0, to: progress)
16                    .stroke(Color(hue: 0.0, saturation: 0.5, brightness: 0.9),
17                            style: StrokeStyle(lineWidth: 20.0, lineCap: .round)
18                    )
19                    .rotationEffect(Angle(degrees: -90))
20                Circle()
21                    .fill(Color.white)
22                    .frame(width: 21, height: 21)
23                    .offset(y: -ringDiameter / 2.0)
24                    .rotationEffect(rotationAngle)
25            }
26            .frame(width: ringDiameter, height: ringDiameter)
27
28            Spacer()
29        }
30        .padding(.vertical, 80)
31    }
32}

Start circular slider with three circles in SwiftUI
Start circular slider with three circles in SwiftUI



Hook up the progress to thumb position

Change the progress variable to be a State variable and add a default Slider. This Slider is used to change the value of the progress and implement enough code on the circular slider to get the thumb and progress arc to respond. The current value is shown in the center of the circular slider.

 1struct CircularSliderView2: View {
 2    @State var progress = 0.33
 3    let ringDiameter = 300.0
 4    
 5    private var rotationAngle: Angle {
 6        return Angle(degrees: (360.0 * progress))
 7    }
 8    
 9    var body: some View {
10        ZStack {
11            Color(hue: 0.58, saturation: 0.04, brightness: 1.0)
12                .edgesIgnoringSafeArea(.all)
13            
14            VStack {
15                ZStack {
16                    Circle()
17                        .stroke(Color(hue: 0.0, saturation: 0.0, brightness: 0.9), lineWidth: 20.0)
18                        .overlay() {
19                            Text("\(progress, specifier: "%.1f")")
20                                .font(.system(size: 78, weight: .bold, design:.rounded))
21                        }
22                    Circle()
23                        .trim(from: 0, to: progress)
24                        .stroke(Color(hue: 0.0, saturation: 0.5, brightness: 0.9),
25                                style: StrokeStyle(lineWidth: 20.0, lineCap: .round)
26                        )
27                        .rotationEffect(Angle(degrees: -90))
28                    Circle()
29                        .fill(Color.white)
30                        .shadow(radius: 3)
31                        .frame(width: 21, height: 21)
32                        .offset(y: -ringDiameter / 2.0)
33                        .rotationEffect(rotationAngle)
34                }
35                .frame(width: ringDiameter, height: ringDiameter)
36                
37                
38                VStack {
39                    Text("Progress: \(progress, specifier: "%.1f")")
40                    Slider(value: $progress,
41                           in: 0...1,
42                           minimumValueLabel: Text("0.0"),
43                           maximumValueLabel: Text("1.0")
44                    ) {}
45                }
46                .padding(.vertical, 40)
47                
48                Spacer()
49            }
50            .padding(.vertical, 40)
51            .padding()
52        }
53    }
54}

Use linear slider to ensure circular slider responds to changes in values
Use linear slider to ensure circular slider responds to changes in values



Add touch gesture

A DragGesture is added to the thumb circle and a temporary Text view is used to show the current location as the Drag Gesture is in progress. It can be seen that the x and y coordinates change around the center of the frame containing the Circular Slider.

 1struct CircularSliderView3: View {
 2    @State var progress = 0.33
 3    let ringDiameter = 300.0
 4    
 5    @State var loc = CGPoint(x: 0, y: 0)
 6    
 7    private var rotationAngle: Angle {
 8        return Angle(degrees: (360.0 * progress))
 9    }
10    
11    private func changeAngle(location: CGPoint) {
12        loc = location
13    }
14    
15    var body: some View {
16        ZStack {
17            Color(hue: 0.58, saturation: 0.04, brightness: 1.0)
18                .edgesIgnoringSafeArea(.all)
19            
20            VStack {
21                ZStack {
22                    Circle()
23                        .stroke(Color(hue: 0.0, saturation: 0.0, brightness: 0.9), lineWidth: 20.0)
24                        .overlay() {
25                            Text("\(progress, specifier: "%.1f")")
26                                .font(.system(size: 78, weight: .bold, design:.rounded))
27                        }
28                    Circle()
29                        .trim(from: 0, to: progress)
30                        .stroke(Color(hue: 0.0, saturation: 0.5, brightness: 0.9),
31                                style: StrokeStyle(lineWidth: 20.0, lineCap: .round)
32                        )
33                        .rotationEffect(Angle(degrees: -90))
34                    Circle()
35                        .fill(Color.blue)
36                        .shadow(radius: 3)
37                        .frame(width: 21, height: 21)
38                        .offset(y: -ringDiameter / 2.0)
39                        .rotationEffect(rotationAngle)
40                        .gesture(
41                            DragGesture(minimumDistance: 0.0)
42                                .onChanged() { value in
43                                    changeAngle(location: value.location)
44                                }
45                        )
46                }
47                .frame(width: ringDiameter, height: ringDiameter)
48                
49                Spacer().frame(height:50)
50                
51                Text("Location = (\(loc.x, specifier: "%.1f"), \(loc.y, specifier: "%.1f"))")
52                
53                Spacer()
54            }
55            .padding(.vertical, 40)
56            .padding()
57        }
58    }
59}

Use a temporary State variable to show how the location point changes with the Drag Gesture

Use a temporary State variable to show how the location point changes with the Drag Gesture



Calculate angle for location

Add a private function to calculate the angle the thumb needs to move to from the location points in the drag gesture. A CGVector is created from the center to the location point and the angle is calculated using the ArcTangent function atan2. The angle calculated needs to be adjusted for becoming negative on the left of the center of the frame.

 1struct CircularSliderView4: View {
 2    @State var progress = 0.33
 3    let ringDiameter = 300.0
 4    @State var rotationAngle = Angle(degrees: 120)
 5    
 6    private func changeAngle(location: CGPoint) -> Angle{
 7        // Create a Vector for the location (reversing the y-coordinate system on iOS)
 8        let vector = CGVector(dx: location.x, dy: -location.y)
 9        
10        // Calculate the angle of the vector
11        let angleRadians = atan2(vector.dx, vector.dy)
12        
13        // Convert the angle to a range from 0 to 360 (rather than having negative angles)
14        let positiveAngle = angleRadians < 0.0 ? angleRadians + (2.0 * .pi) : angleRadians
15        
16        // Update slider progress value based on angle
17        progress = positiveAngle / (2.0 * .pi)
18
19        return Angle(radians: positiveAngle)
20    }
21    
22    var body: some View {
23        ZStack {
24            Color(hue: 0.58, saturation: 0.04, brightness: 1.0)
25                .edgesIgnoringSafeArea(.all)
26            
27            VStack {
28                ZStack {
29                    Circle()
30                        .stroke(Color(hue: 0.0, saturation: 0.0, brightness: 0.9), lineWidth: 20.0)
31                        .overlay() {
32                            Text("\(progress, specifier: "%.2f")")
33                                .font(.system(size: 78, weight: .bold, design:.rounded))
34                        }
35                    Circle()
36                        .trim(from: 0, to: progress)
37                        .stroke(Color(hue: 0.0, saturation: 0.5, brightness: 0.9),
38                                style: StrokeStyle(lineWidth: 20.0, lineCap: .round)
39                        )
40                        .rotationEffect(Angle(degrees: -90))
41                    Circle()
42                        .fill(Color.white)
43                        .shadow(radius: 3)
44                        .frame(width: 21, height: 21)
45                        .offset(y: -ringDiameter / 2.0)
46                        .rotationEffect(rotationAngle)
47                        .gesture(
48                            DragGesture(minimumDistance: 0.0)
49                                .onChanged() { value in
50                                    rotationAngle = changeAngle(location: value.location)
51                                }
52                        )
53                }
54                .frame(width: ringDiameter, height: ringDiameter)
55                
56                Spacer()
57            }
58            .padding(.vertical, 40)
59            .padding()
60        }
61    }
62}

Calculate the angle from drag gesture location point and update progress

Calculate the angle from drag gesture location point and update progress



Configure slider for different values

There are two values that represent the progress on the circular slider, the progress value used to display the progress arc and the rotationAngle used to display the slider cursor. There should be only one property to hold the slider progress. The view is extracted to a separate struct with the one binding value for the progress on the circular slider.

An optional parameter for the range on the Slider is also available. This requires a couple of adjustments to the progress to calculate the angle that has been set as well as the rotation angle for the position of the thumb on the circular slider. In addition, onAppear is called to calculate the rotation angle from the progress value before the View appears.

 1struct CircularSliderView: View {
 2    @Binding var progress: Double
 3
 4    @State private var rotationAngle = Angle(degrees: 0)
 5    private var minValue = 0.0
 6    private var maxValue = 1.0
 7    
 8    init(value progress: Binding<Double>, in bounds: ClosedRange<Int> = 0...1) {
 9        self._progress = progress
10        
11        self.minValue = Double(bounds.first ?? 0)
12        self.maxValue = Double(bounds.last ?? 1)
13        self.rotationAngle = Angle(degrees: progressFraction * 360.0)
14    }
15    
16    private var progressFraction: Double {
17        return ((progress - minValue) / (maxValue - minValue))
18    }
19    
20    private func changeAngle(location: CGPoint) {
21        // Create a Vector for the location (reversing the y-coordinate system on iOS)
22        let vector = CGVector(dx: location.x, dy: -location.y)
23        
24        // Calculate the angle of the vector
25        let angleRadians = atan2(vector.dx, vector.dy)
26        
27        // Convert the angle to a range from 0 to 360 (rather than having negative angles)
28        let positiveAngle = angleRadians < 0.0 ? angleRadians + (2.0 * .pi) : angleRadians
29        
30        // Update slider progress value based on angle
31        progress = ((positiveAngle / (2.0 * .pi)) * (maxValue - minValue)) + minValue
32        rotationAngle = Angle(radians: positiveAngle)
33    }
34    
35    var body: some View {
36        GeometryReader { gr in
37            let radius = (min(gr.size.width, gr.size.height) / 2.0) * 0.9
38            let sliderWidth = radius * 0.1
39            
40            VStack(spacing:0) {
41                ZStack {
42                    Circle()
43                        .stroke(Color(hue: 0.0, saturation: 0.0, brightness: 0.9),
44                                style: StrokeStyle(lineWidth: sliderWidth))
45                        .overlay() {
46                            Text("\(progress, specifier: "%.0f")")
47                                .font(.system(size: radius * 0.7, weight: .bold, design:.rounded))
48                        }
49                    // uncomment to show tick marks
50                    //Circle()
51                    //    .stroke(Color(hue: 0.0, saturation: 0.0, brightness: 0.6),
52                    //            style: StrokeStyle(lineWidth: sliderWidth * 0.75,
53                    //                               dash: [2, (2 * .pi * radius)/24 - 2]))
54                    //    .rotationEffect(Angle(degrees: -90))
55                    Circle()
56                        .trim(from: 0, to: progressFraction)
57                        .stroke(Color(hue: 0.0, saturation: 0.5, brightness: 0.9),
58                                style: StrokeStyle(lineWidth: sliderWidth, lineCap: .round)
59                        )
60                        .rotationEffect(Angle(degrees: -90))
61                    Circle()
62                        .fill(Color.white)
63                        .shadow(radius: (sliderWidth * 0.3))
64                        .frame(width: sliderWidth, height: sliderWidth)
65                        .offset(y: -radius)
66                        .rotationEffect(rotationAngle)
67                        .gesture(
68                            DragGesture(minimumDistance: 0.0)
69                                .onChanged() { value in
70                                    changeAngle(location: value.location)
71                                }
72                        )
73                }
74                .frame(width: radius * 2.0, height: radius * 2.0, alignment: .center)
75                .padding(radius * 0.1)
76            }
77            
78            .onAppear {
79                self.rotationAngle = Angle(degrees: progressFraction * 360.0)
80            }
81        }
82    }
83}

Three different views of CircularSliderView are added to a view to test and demonstrate the different features of the Circular Slider view.

 1struct CircularSliderView5: View {
 2    @State var progress1 = 0.75
 3    @State var progress2 = 37.5
 4    @State var progress3 = 7.5
 5    
 6    var body: some View {
 7        ZStack {
 8            Color(hue: 0.58, saturation: 0.06, brightness: 1.0)
 9                .edgesIgnoringSafeArea(.all)
10
11            VStack {
12                CircularSliderView(value: $progress1)
13                    .frame(width:250, height: 250)
14                
15                HStack {
16                    CircularSliderView(value: $progress2, in: 32...50)
17
18                    CircularSliderView(value: $progress3, in: 0...100)
19                }
20                
21                Spacer()
22            }
23            .padding()
24        }
25    }
26}

Multiple circular sliders with separate ranges in SwiftUI
Multiple circular sliders with separate ranges in SwiftUI




Circular slider from 32 to 50 in SwiftUI

Circular slider from 32 to 50 in SwiftUI





Conclusion

This article how to define a circular slider control that responds to drag gesture. The size of the containing frame can be set and the slider works as expected. More parameters could be added to the control to set the colors or the format of the values displayed inside the circular slider. The code for Circular Slider is available on GitHub.