9

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

Mohammad Mugish
  • 335
  • 1
  • 3
  • 13
  • Sorry, first, I have not read it correctly. I started to look into "how to pass parameters to `UIViewRepresentable`". But later realised, you wanted the full swiftUI stuff. So I guess the question in how can you extract the modifier information from the original view. – gujci Oct 08 '19 at 13:06
  • Yes. There are two things. First is, Create Slider from scratch by using SwiftUI or Second is to Create Slider by extracting something from the UIKit library. Both are good options because my focus is to design this slider in SwiftUI. – Mohammad Mugish Oct 08 '19 at 13:12
  • For the second thing, wrapping it is the solution, if I understand you correctly. This snippet might be something for starter. https://gist.github.com/Gujci/7a7c37ce6a4bc29c498ca3c593bf2b69 – gujci Oct 08 '19 at 13:37
  • Also, `accentColor` depends on the context, so, if you make a new swiftUI view and use `.foregroundColor(.accentColor)` it will be the one, you set on the outer level. – gujci Oct 08 '19 at 13:43
  • Also I don't think, that SwiftUI color can be translated back to UIKit with a public API. – gujci Oct 08 '19 at 13:50

5 Answers5

19

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)
    ...
gujci
  • 1,238
  • 13
  • 21
  • Thanks for this solution. I got it. – Mohammad Mugish Oct 08 '19 at 14:20
  • I found one problem with this. If I want to change the value of the slider by just tapping anywhere on the slider, but the value is not getting changed. I need to drag it, then the only value is getting changed. How can I change the value by just tapping anywhere on the slider? – Mohammad Mugish Oct 11 '19 at 05:47
  • LOL, It's much more simple, use `DragGesture(minimumDistance: 0)`. [Found here](https://stackoverflow.com/questions/56513942/how-to-detect-a-tap-gesture-location-in-swiftui) – gujci Oct 11 '19 at 08:49
  • This got me on the right path. Combine this with an @EnvironmentObject holding an audio player, and you have got the start of a simple, dynamic SwiftUI audio player tracker – eResourcesInc Jan 29 '20 at 16:43
11

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)
    }
}
Den
  • 3,179
  • 29
  • 26
  • Hey Den! This code is amazing. I was wondering if you had some time where I could pay you to tutor me on swift. I am trying to understand how this works, and getting hung up on how the heck the number gets updated. Thanks! – JohnKubik Oct 30 '21 at 09:02
  • @JohnKubik I think WWDC is the best tutor. Swift and SwiftUI are still babies, so you can learn quickly and easily through WWDC and keep up with the latest technology. I also recommend the free iOS courses from Stanford University. You can learn about SwiftUI. https://www.youtube.com/watch?v=bqu6BquVi2M – Den Nov 02 '21 at 00:33
  • This code is great, but it completely ignores the lower bound of the range, so all sliders made with it start at 0. I had to fix it to take it into account by shifting the ratio and value calculations down to a true range. – ImTheSquid Jul 05 '23 at 22:02
1

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

gaohomway
  • 2,132
  • 1
  • 20
  • 37
0

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)
    })
Gurkan Soykan
  • 156
  • 2
  • 6
0

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
        }
    }
}
Eric Aya
  • 69,473
  • 35
  • 181
  • 253