CompactSlider icon indicating copy to clipboard operation
CompactSlider copied to clipboard

cannot add tappable item into slider

Open robaho opened this issue 10 months ago • 16 comments

Hi.

I'd like to be able to add tappable items into the slider.

something like:

HStack { Text("Left").onTapGesture(...); Spacer(); Text("Right").onTapGesture(...) }

I have tried using the .compactSliderBackground and .overlay and .background

If I use the .overlay I can get it to work, but if I try to drag the slider starting on one the tappable elements it won't drag.

I would think the proper place to add this is the .compactSliderBackground but I think maybe how the slider gestures are registered do not allow this to work.

I am using 2.0.8 on OSX 15.3

Any ideas? Thanks.

robaho avatar Mar 26 '25 04:03 robaho

I tried playing with the minimum drag distance option, and that didn't help.

robaho avatar Mar 26 '25 05:03 robaho

I also tried using the simultaneous and high priority options - couldn't make it work.

Is there an example of something like this I can review?

robaho avatar Mar 26 '25 05:03 robaho

Hi! Could you try to use simultaneous for the tap gestures?

buh avatar Mar 26 '25 09:03 buh

Hi. I have. Should I add them to the compactSliderBackground? Or to an overlay?

robaho avatar Mar 26 '25 14:03 robaho

You could add it to the overlay with your labels.

buh avatar Mar 26 '25 14:03 buh

Here is the code. I cannot get the labels to be clickable and also be able to start a drag of the slider not matter what I try. Thank you for your help.

import SwiftUI
import CompactSlider

struct MySlider : View {
    @State var value : Double = 0.5
    var body: some View {
        CompactSlider(value: $value, in: 0...1, step: 0.05)
            .compactSliderOptionsByAdding(.snapToSteps,.highPriorityGesture,.scrollWheel,.dragGestureMinimumDistance(5))
            .compactSliderScale(
                visibility: .default,
                alignment: .top,
                .linear(count: 21, lineLength: 8, skipFirst: 1, skipLast: 1, startFromCenter: true)
            )
            .overlay {
                HStack {
                    Text("Click #1")
                        .lineLimit(1)
                        .minimumScaleFactor(0.5)
                        .clipShape(Rectangle())
                        .onTapGesture {
                            print("Click #1 was clicked!")
                        }
                    Spacer()
                    Text("Click #2")
                        .lineLimit(1)
                        .minimumScaleFactor(0.5)
                        .clipShape(Rectangle())
                        .simultaneousGesture (
                            TapGesture()
                                .onEnded {
                                    print("Click #2 was clicked!")
                                }
                        )

                }
            }
            .frame(width:200, height:24)
            .onAppear {
                print("I'm here!")
            }
    }
}

#Preview {
    MySlider()
}

robaho avatar Mar 26 '25 16:03 robaho

Do you have any ideas for this? It should be possible (I think), as a SwiftUI you can add a tap gesture to an item, but even if you do, the drag and drop of the item to reorder in the list still works.

I would have thought that increasing the drag gesture distance requirement on the slider would have fixed it but it did not.

robaho avatar Apr 03 '25 05:04 robaho

I was thinking to add an event inside the slider to detect if there was a tap and provide a location of it. Will it help?

buh avatar Apr 05 '25 09:04 buh

Thanks for looking into it. I decided to just roll my own simple slider and it works as expected so I’m not sure what the interaction is that causes CompactSlider not to work.

robaho avatar Apr 05 '25 12:04 robaho

I've decided to add an option to provide an external drag gestures from overlay layers.

buh avatar Apr 05 '25 12:04 buh

One note, I’ve found supporting multiple drag gestures problematic. You almost always need to simulate a tap using a drag with minimum distance of 0. It gets messy and error prone.

To he honest I was surprised my slider solution worked based on past experiences, but it seems to distinguish between the tap on the labels and the drag on the background/scale/handle perfectly.

TLDR I would try and support taps and implement the component behaviors with drags.

robaho avatar Apr 05 '25 12:04 robaho

That's great! Please share your solution, maybe I could improve mine.

buh avatar Apr 05 '25 12:04 buh

Currently I'm testing this:

        CompactSlider(value: $value, in: 0...1)
            .compactSliderExternalDragGesture(externalDragGesture)
            .overlay {
                HStack {
                    Text("Tap on Text #1")
                        .lineLimit(1)
                        .minimumScaleFactor(0.5)
                        .clipShape(Rectangle())
                        .onTapGesture {
                            tapEffect = "Tap #1 on text!"
                        }
                        .simultaneousGesture(
                            DragGesture()
                                .onChanged { externalDragGesture = .init(onChanged: $0) }
                                .onEnded { externalDragGesture = .init(onEnded: $0) }
                        )
// ...

buh avatar Apr 05 '25 12:04 buh

Ah I see. That is different than what I was thinking and how my solution works. Give me a bit to simplify the code to the essentials and I’ll share. It makes a lot of assumptions but it works in my case.

robaho avatar Apr 05 '25 12:04 robaho

I've been able to make it work, but trying to simplify the API.

buh avatar Apr 05 '25 12:04 buh

Here is my code. It's not the real version but enough to demonstrate the dragging starting on the labels, and the ability to tap the labels as well.

//
//  MySlider.swift
//
//  Created by robert engels on 12/2/24.
//
import Foundation
import SwiftUI
import Swift

private struct SliderScale : Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()
        let minorSpacing = rect.width / 20
        let majorSpacing = rect.width / 10
        for x in stride(from: 0, to: rect.width, by: minorSpacing)  {
            path.move(to: CGPoint(x:x,y:0))
            path.addLine(to: CGPoint(x:x,y:rect.height * 0.20))
        }
        for x in stride(from: 0, to: rect.width, by: majorSpacing)  {
            path.move(to: CGPoint(x:x,y:0))
            path.addLine(to: CGPoint(x:x,y:rect.height * 0.40))
        }
        return path.offsetBy(dx: rect.origin.x, dy: rect.origin.y)
    }
}

private func range_percent<T: BinaryFloatingPoint>(at value: T, in range: ClosedRange<T>) -> Double {
    return Double((value - range.lowerBound) / (range.upperBound - range.lowerBound))
}

private struct HandleView : Shape {
    @State var percent : Double
    func path(in rect: CGRect) -> Path {
        var path = Path()
        let x = rect.width * Double(percent)
        path.move(to: CGPoint(x:x,y:0))
        path.addLine(to: CGPoint(x:x,y:rect.height))
        return path.offsetBy(dx: rect.origin.x, dy: rect.origin.y)
    }
}

struct MySlider<T : BinaryFloatingPoint & Comparable & Sendable> : View {
    private var label : String
    private var value : Binding<T>
    private var bounds: ClosedRange<T>
    private var step : T
    @State private var active = false
    @State private var dragStart : T?

    init(label: String, value: Binding<T>, in bounds: ClosedRange<T> = 0...1, step: T) {
        self.label = label
        self.value = value
        self.bounds = bounds
        self.step = step
    }

    var dragGesture: some Gesture {
        DragGesture(minimumDistance: 1.0)
            .onChanged { v in
                if dragStart == nil {
                    dragStart = value.wrappedValue
                }

                // Total logical range
                let totalRange = CGFloat(bounds.upperBound - bounds.lowerBound)

                // Define how many pixels equal the full range (adjust as needed)
                let dragWidth: CGFloat = 200 // e.g., 200 points = full range
                let scale = totalRange / dragWidth

                let delta = v.translation.width * scale
                let newValue = CGFloat(dragStart!) + delta

                value.wrappedValue = min(max(T(newValue), bounds.lowerBound), bounds.upperBound)
            }
            .onEnded { _ in
                dragStart = nil
            }
    }

    var body: some View {
        ZStack
        {
            RoundedRectangle(cornerRadius: 6)
                .fill(.gray.opacity(0.125))
            SliderScale()
                .stroke(.gray.opacity(0.50), lineWidth: 2)
                .opacity(active ? 1.0 : 0.0)
            HandleView(percent: range_percent(at: value.wrappedValue, in: bounds))
                .stroke(active ? AnyShapeStyle(.tint) : AnyShapeStyle(Color.gray.opacity(0.75)), lineWidth: 3)
            HStack {
                Text(label)
                    .lineLimit(1)
                    .minimumScaleFactor(0.5)
                    .clipShape(Rectangle())
                    .highPriorityGesture(TapGesture().onEnded {
                        value.wrappedValue = max(value.wrappedValue - step,bounds.lowerBound)
                    })
                Spacer()
                Text(String(format: "%0.2f", Double(value.wrappedValue)))
                    .lineLimit(1)
                    .minimumScaleFactor(0.25)
                    .clipShape(Rectangle())
                    .onTapGesture {
                        value.wrappedValue = max(value.wrappedValue + step,bounds.lowerBound)
                    }
            }
            .padding(3)
        }
        .highPriorityGesture(dragGesture)
        .frame(height:24, alignment: .top)
        .onHover { hovering in self.active = hovering }
        .frame(height:24)
    }
}

#Preview {
    @Previewable @State var value : Double = 0.5
    MySlider(label: "My Label", value: $value, in: 0...1, step: 0.05)
}

robaho avatar Apr 05 '25 13:04 robaho