Mohammad Mugish
Mohammad Mugish

Reputation: 335

How to create custom slider by using SwiftUI?

I am able to create a Slider by using SwiftUI but I am not able to change the style of the slider as shown in the image(below).

Problem: I am not able to find any option in SwiftUI to change the slider style.

Note: I want to create this by using SwiftUI only. I already created this slider in Swift by using "https://github.com/daprice/iOS-Tactile-Slider"

I have tried following but it's not the solution :

1. Slider(value: .constant(0.3)).accentColor(Color.white)

2. Slider(value: $age, in: 18...20, step: 1, minimumValueLabel: Text("18"), maximumValueLabel: Text("20")) { Text("") }

3. Slider(value: $age, in: 18...20, step: 1, minimumValueLabel: Image(systemName: "18.circle"), maximumValueLabel: Image(systemName: "20.circle")) { Text("") }

How can I create a slider with the style as shown in the image using SwiftUI only?

enter image description here

Upvotes: 9

Views: 20243

Answers (5)

nic_niq_nik
nic_niq_nik

Reputation: 1

Credit from https://stackoverflow.com/a/76224773/22404582 (gaohomway)

Here is my revised version: I added a binding so you can link the progress value, and I set a starting state for the bar width.

struct UISliderView: View {

@State var maxWidth: CGFloat = UIScreen.main.bounds.width - 32 // or you can put your slider width
    // min = 0, max = 1.0
    @Binding var sliderProgress: CGFloat
    @State var sliderWidth: CGFloat = 0
    @State var lastDragValue: CGFloat = 0
    
    var body: some View {
        ZStack(alignment: .leading, content: {
            Rectangle()
                .fill(.blue.opacity(0.2))
            
            Rectangle()
                .fill(.blue)
                .frame(width: sliderWidth)
        })
        .frame(width: maxWidth, height: 38)
        .cornerRadius(8)
        .onAppear {
            sliderWidth = maxWidth * sliderProgress
            lastDragValue = sliderWidth
        }
        .gesture(DragGesture(minimumDistance: 0).onChanged({ value in
            
            updateSlideWidth(translationWidth: value.translation.width)
            
            updateSlideProgress()
            
        }).onEnded({ _ in
            updateSlideWidth(translationWidth: nil)
        }))
    }
    private func updateSlideProgress() {
        let progress = sliderWidth / maxWidth
        sliderProgress = progress <= 1.0 ? progress : 1
    }
    
    private func updateSlideWidth(translationWidth: CGFloat?) {
        if let translationWidth = translationWidth {
            sliderWidth = translationWidth + lastDragValue
        } else {
            sliderWidth = sliderWidth > maxWidth ? maxWidth : sliderWidth
        }
        
        sliderWidth = sliderWidth >= 0 ? sliderWidth : 0
        if translationWidth == nil {
            lastDragValue = sliderWidth
        }
    }
}

Upvotes: 0

gaohomway
gaohomway

Reputation: 4060

This can meet your needs

enter image description here


let width = UIScreen.main.bounds.width

struct Home: View {
    
    @State var maxWidth: CGFloat = width - 32

    @State var sliderProgress: CGFloat = 0
    @State var sliderWidth: CGFloat = 0
    @State var lastDragValue: CGFloat = 0
    
    var body: some View {
        NavigationView {
            VStack {
                ZStack(alignment: .leading, content: {
                    Rectangle()
                        .fill(.blue.opacity(0.2))
                        
                    Rectangle()
                        .fill(.blue)
                        .frame(width: sliderWidth)
                })
                .frame(width: maxWidth, height: 32)
                .cornerRadius(35)

                .overlay(alignment: .leading) {
                    Text("\(Int(sliderProgress * 100))%")
                        .fontWeight(.semibold)
                        .foregroundColor(.black)
                        .offset(x: sliderWidth - 16, y: -64)
                }
                .gesture(DragGesture(minimumDistance: 0).onChanged({ (value) in
                    
                    let translation = value.translation
                    
                    sliderWidth = translation.width + lastDragValue
                    
                    sliderWidth = sliderWidth > maxWidth ? maxWidth : sliderWidth
                    
                    sliderWidth = sliderWidth >= 0 ? sliderWidth : 0
                                        
                    let progress = sliderWidth / maxWidth
                    
                    sliderProgress = progress <= 1.0 ? progress : 1
                    
                }).onEnded({ (value) in
                    
                    sliderWidth = sliderWidth > maxWidth ? maxWidth : sliderWidth
                    
                    // Negative Height....
                    sliderWidth = sliderWidth >= 0 ? sliderWidth : 0
                    
                    lastDragValue = sliderWidth
                    
                }))
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .navigationTitle("Dashboard")
            .navigationBarTitleDisplayMode(.inline)
        }
    }
}

Upvotes: 1

Gurkan Soykan
Gurkan Soykan

Reputation: 196

Here is a good solution for Custom Slider. https://swiftuirecipes.com/blog/custom-slider-in-swiftui

I can confirm that this works. In addition, if you'd want it to be snappy you should add onEnded closure the following

.onEnded({ _ in
      // once the gesture ends, trigger `onEditingChanged` again
        xOffset = (trackSize.width - thumbSize.width) * CGFloat(percentage)
        lastOffset = xOffset
      onEditingChanged?(false)
    })

Upvotes: 0

Den
Den

Reputation: 3591

In my case, I had to customize the thumb. (ex. screen locker)

I leave an answer for a problem similar one.


enter image description here



LockerSlider.swift

import SwiftUI

struct LockerSlider<V>: View where V : BinaryFloatingPoint, V.Stride : BinaryFloatingPoint {

    // MARK: - Value
    // MARK: Private
    @Binding private var value: V
    private let bounds: ClosedRange<V>
    private let step: V.Stride

    private let length: CGFloat    = 50
    private let lineWidth: CGFloat = 2

    @State private var ratio: CGFloat   = 0
    @State private var startX: CGFloat? = nil


    // MARK: - Initializer
    init(value: Binding<V>, in bounds: ClosedRange<V>, step: V.Stride = 1) {
        _value  = value
    
        self.bounds = bounds
        self.step   = step
    }


    // MARK: - View
    // MARK: Public
    var body: some View {
        GeometryReader { proxy in
            ZStack(alignment: .leading) {
                // Track
                RoundedRectangle(cornerRadius: length / 2)
                    .foregroundColor(Color(#colorLiteral(red: 0.8039215803, green: 0.8039215803, blue: 0.8039215803, alpha: 1)))
            
                // Thumb
                Circle()
                    .foregroundColor(.white)
                    .frame(width: length, height: length)
                    .offset(x: (proxy.size.width - length) * ratio)
                    .gesture(DragGesture(minimumDistance: 0)
                                .onChanged({ updateStatus(value: $0, proxy: proxy) })
                                .onEnded { _ in startX = nil })
            }
            .frame(height: length)
            .overlay(overlay)
            .simultaneousGesture(DragGesture(minimumDistance: 0)
                                    .onChanged({ update(value: $0, proxy: proxy) }))
            .onAppear {
                ratio = min(1, max(0,CGFloat(value / bounds.upperBound)))
            }
        }
    }

    // MARK: Private
    private var overlay: some View {
        RoundedRectangle(cornerRadius: (length + lineWidth) / 2)
            .stroke(Color.gray, lineWidth: lineWidth)
            .frame(height: length + lineWidth)
    }


    // MARK: - Function
    // MARK: Private
    private func updateStatus(value: DragGesture.Value, proxy: GeometryProxy) {
        guard startX == nil else { return }
    
        let delta = value.startLocation.x - (proxy.size.width - length) * ratio
        startX = (length < value.startLocation.x && 0 < delta) ? delta : value.startLocation.x
    }

    private func update(value: DragGesture.Value, proxy: GeometryProxy) {
        guard let x = startX else { return }
        startX = min(length, max(0, x))
    
        var point = value.location.x - x
        let delta = proxy.size.width - length
    
        // Check the boundary
        if point < 0 {
            startX = value.location.x
            point = 0
        
        } else if delta < point {
            startX = value.location.x - delta
            point = delta
        }
    
        // Ratio
        var ratio = point / delta
    
    
        // Step
        if step != 1 {
            let unit = CGFloat(step) / CGFloat(bounds.upperBound)
        
            let remainder = ratio.remainder(dividingBy: unit)
            if remainder != 0 {
                ratio = ratio - CGFloat(remainder)
            }
        }
    
        self.ratio = ratio
        self.value = V(bounds.upperBound) * V(ratio)
    }
}

Demo.swift

import SwiftUI

struct Demo: View {

    // MARK: - Value
    // MARK: Private
    @State private var number = 150000.0


    // MARK - View
    // MARK: Public
    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            Text("Number\n\(number)")
                .bold()
                .padding(.bottom, 20)
        
            Text("OS Slider")
            Slider(value: $number, in: 0...1050000, step: 0.02)
                .padding(.bottom, 20)
        
            Text("Custom Slider")
            LockerSlider(value: $number, in: 0...1050000, step: 0.02)
                .padding(.bottom, 20)
        }
        .padding(20)
    }
}

Upvotes: 12

gujci
gujci

Reputation: 1284

As it turned out for me accent color is depending on the context, as well as the frame, so we don't need to handle that.

As far as the control goes I made a really dummy and simple example. Please do not consider this as a solution, rather a starter.

struct CustomView: View {

    @Binding var percentage: Float // or some value binded

    var body: some View {
        GeometryReader { geometry in
            // TODO: - there might be a need for horizontal and vertical alignments
            ZStack(alignment: .leading) {
                Rectangle()
                    .foregroundColor(.gray)
                Rectangle()
                    .foregroundColor(.accentColor)
                    .frame(width: geometry.size.width * CGFloat(self.percentage / 100))
            }
            .cornerRadius(12)
            .gesture(DragGesture(minimumDistance: 0)
                .onChanged({ value in
                    // TODO: - maybe use other logic here
                    self.percentage = min(max(0, Float(value.location.x / geometry.size.width * 100)), 100)
                }))
        }
    }
}

You can use it like

    @State var percentage: Float = 50

    ...
    var body: some View {
    ...
            CustomView(percentage: $percentage)
                .accentColor(.red)
                .frame(width: 200, height: 44)
    ...

Upvotes: 22

Related Questions