@@ -11,15 +11,33 @@ final class RNCSliderModel: ObservableObject {
1111 @Published var minimumValue : Double = 0
1212 @Published var maximumValue : Double = 1
1313 @Published var value : Double = 0
14+ @Published var ranged : Bool = false
15+ @Published var valueLeft : Double = 0
16+ @Published var valueRight : Double = 1
1417
1518 /// While the user drags, the thumb position is owned by this view. Value
1619 /// updates coming from JS in the meantime would fight the gesture, so they are
1720 /// ignored - the slider is uncontrolled for the duration of the drag.
1821 var isSliding = false
1922
23+ /// The value a ranged thumb had when the drag in progress began, or `nil` when
24+ /// that thumb is not being dragged - which is also how the ranged slider knows
25+ /// to ignore values pushed from JS, the way `isSliding` does for the single
26+ /// thumb.
27+ ///
28+ /// A drag is tracked by how far it has travelled rather than by where the
29+ /// finger is, so grabbing a thumb off-centre does not snap it under the finger.
30+ var leftDragOrigin : Double ?
31+ var rightDragOrigin : Double ?
32+
2033 /// Invoked continuously while the user drags the thumb.
2134 var onValueChange : ( ( Double ) -> Void ) ?
2235
36+ /// Invoked continuously while the user drags the respective thumb of a ranged
37+ /// slider.
38+ var onLeftValueChange : ( ( Double ) -> Void ) ?
39+ var onRightValueChange : ( ( Double ) -> Void ) ?
40+
2341 /// SwiftUI traps on an empty or descending range. Props arrive one at a time,
2442 /// so a momentarily inverted range is expected rather than exceptional.
2543 var range : ClosedRange < Double > {
@@ -30,6 +48,18 @@ final class RNCSliderModel: ObservableObject {
3048struct RNCSliderContent : View {
3149 @ObservedObject var model : RNCSliderModel
3250
51+ var body : some View {
52+ if model. ranged {
53+ RNCRangedSliderContent ( model: model)
54+ } else {
55+ RNCSingleSliderContent ( model: model)
56+ }
57+ }
58+ }
59+
60+ struct RNCSingleSliderContent : View {
61+ @ObservedObject var model : RNCSliderModel
62+
3363 var body : some View {
3464 let range = model. range
3565
@@ -47,6 +77,190 @@ struct RNCSliderContent: View {
4777 }
4878}
4979
80+ /// SwiftUI has no two-thumb slider, so this one is drawn here: a track, the
81+ /// selected span, and a thumb at each end of it. The shape follows `Slider` -
82+ /// a capsule track with the selected part tinted, and a white circular thumb -
83+ /// so that a ranged slider does not look foreign next to a plain one.
84+ struct RNCRangedSliderContent : View {
85+ @ObservedObject var model : RNCSliderModel
86+
87+ /// Which end of the range a thumb drags.
88+ private enum Thumb {
89+ case left
90+ case right
91+ }
92+
93+ var body : some View {
94+ GeometryReader { geometry in
95+ let range = model. range
96+ // A thumb is never taller than the slider itself: JS decides the height,
97+ // and a thumb spilling out of it would draw over its neighbours.
98+ let diameter = min ( Self . thumbDiameter, geometry. size. height)
99+ // The span the centre of a thumb moves across. Both thumbs stay fully
100+ // inside the view, so it is short of the width by one thumb.
101+ let travel = max ( geometry. size. width - diameter, 0 )
102+
103+ let leftOffset = offset ( of: model. valueLeft, in: range, travel: travel)
104+ let rightOffset = offset ( of: model. valueRight, in: range, travel: travel)
105+
106+ ZStack ( alignment: . leading) {
107+ Capsule ( )
108+ . fill ( Self . trackColor)
109+ . frame ( height: Self . trackHeight)
110+
111+ // Drawn between the two thumb centres, which is why it is inset by half
112+ // a thumb. `max` keeps it from inverting on values crossed by JS.
113+ Capsule ( )
114+ . fill ( Color . accentColor)
115+ . frame ( width: max ( rightOffset - leftOffset, 0 ) , height: Self . trackHeight)
116+ . offset ( x: min ( leftOffset, rightOffset) + diameter / 2 )
117+
118+ thumb ( . left, diameter: diameter, offset: leftOffset, range: range, travel: travel)
119+ // Both thumbs pinned to the upper bound overlap exactly, and the right
120+ // one has nowhere left to go - so the left one has to take the touches
121+ // or the pair is stuck there.
122+ . zIndex ( model. valueLeft >= range. upperBound ? 1 : 0 )
123+
124+ thumb ( . right, diameter: diameter, offset: rightOffset, range: range, travel: travel)
125+ }
126+ . frame ( width: geometry. size. width, height: geometry. size. height)
127+ }
128+ }
129+
130+ private func thumb(
131+ _ thumb: Thumb ,
132+ diameter: CGFloat ,
133+ offset: CGFloat ,
134+ range: ClosedRange < Double > ,
135+ travel: CGFloat
136+ ) -> some View {
137+ Circle ( )
138+ . fill ( Color . white)
139+ . shadow ( color: . black. opacity ( 0.25 ) , radius: 2 , y: 1 )
140+ . frame ( width: diameter, height: diameter)
141+ // A thumb is a small thing to grab, and the two of them can end up right
142+ // next to each other, so the area that answers to a touch is padded out
143+ // to the 44pt Apple asks for. The padding is then shifted back off the
144+ // offset, leaving the circle itself where the value puts it.
145+ . padding ( Self . thumbTouchSlop)
146+ . contentShape ( Rectangle ( ) )
147+ . offset ( x: offset - Self. thumbTouchSlop)
148+ . gesture (
149+ DragGesture ( minimumDistance: 0 )
150+ . onChanged { gesture in
151+ let origin = dragOrigin ( of: thumb) ?? value ( of: thumb)
152+ setDragOrigin ( origin, of: thumb)
153+
154+ let travelled = travel > 0
155+ ? Double ( gesture. translation. width / travel) * span( of: range)
156+ : 0
157+ set ( origin + travelled, of: thumb, in: range)
158+ }
159+ . onEnded { _ in setDragOrigin ( nil , of: thumb) }
160+ )
161+ // Drawn rather than built from a UIKit control, so VoiceOver has to be
162+ // told what this is by hand. The 10% step matches `UISlider`.
163+ . accessibilityElement ( )
164+ . accessibilityValue ( Text ( percentage ( of: value ( of: thumb) , in: range) ) )
165+ . accessibilityAdjustableAction { direction in
166+ let step = span ( of: range) / 10
167+ switch direction {
168+ case . increment:
169+ set ( value ( of: thumb) + step, of: thumb, in: range)
170+ case . decrement:
171+ set ( value ( of: thumb) - step, of: thumb, in: range)
172+ @unknown default :
173+ break
174+ }
175+ }
176+ }
177+
178+ // MARK: - Values
179+
180+ private func value( of thumb: Thumb ) -> Double {
181+ switch thumb {
182+ case . left: return model. valueLeft
183+ case . right: return model. valueRight
184+ }
185+ }
186+
187+ /// Moves one thumb, keeping it inside the range and on its own side of the
188+ /// other thumb, and reports it to JS. The two thumbs cannot swap places.
189+ private func set( _ newValue: Double , of thumb: Thumb , in range: ClosedRange < Double > ) {
190+ switch thumb {
191+ case . left:
192+ let clamped = newValue. clamped (
193+ to: range. lowerBound... model. valueRight. clamped ( to: range)
194+ )
195+ guard clamped != model. valueLeft else { return }
196+ model. valueLeft = clamped
197+ model. onLeftValueChange ? ( clamped)
198+ case . right:
199+ let clamped = newValue. clamped (
200+ to: model. valueLeft. clamped ( to: range) ... range. upperBound
201+ )
202+ guard clamped != model. valueRight else { return }
203+ model. valueRight = clamped
204+ model. onRightValueChange ? ( clamped)
205+ }
206+ }
207+
208+ private func dragOrigin( of thumb: Thumb ) -> Double ? {
209+ switch thumb {
210+ case . left: return model. leftDragOrigin
211+ case . right: return model. rightDragOrigin
212+ }
213+ }
214+
215+ private func setDragOrigin( _ origin: Double ? , of thumb: Thumb ) {
216+ switch thumb {
217+ case . left: model. leftDragOrigin = origin
218+ case . right: model. rightDragOrigin = origin
219+ }
220+ }
221+
222+ // MARK: - Geometry
223+
224+ private func span( of range: ClosedRange < Double > ) -> Double {
225+ range. upperBound - range. lowerBound
226+ }
227+
228+ /// Distance from the leading edge of the view to the leading edge of a thumb
229+ /// sitting at `value`.
230+ private func offset(
231+ of value: Double ,
232+ in range: ClosedRange < Double > ,
233+ travel: CGFloat
234+ ) -> CGFloat {
235+ CGFloat ( fraction ( of: value, in: range) ) * travel
236+ }
237+
238+ private func fraction( of value: Double , in range: ClosedRange < Double > ) -> Double {
239+ let span = span ( of: range)
240+ guard span > 0 else { return 0 }
241+
242+ return ( ( value - range. lowerBound) / span) . clamped ( to: 0 ... 1 )
243+ }
244+
245+ private func percentage( of value: Double , in range: ClosedRange < Double > ) -> String {
246+ NumberFormatter . localizedString (
247+ from: NSNumber ( value: fraction ( of: value, in: range) ) ,
248+ number: . percent
249+ )
250+ }
251+
252+ /// Matches the thumb `UISlider` draws.
253+ private static let thumbDiameter : CGFloat = 28
254+
255+ /// Grown around the thumb on every side, to reach 44pt across.
256+ private static let thumbTouchSlop : CGFloat = 8
257+
258+ private static let trackHeight : CGFloat = 4
259+
260+ /// What `Slider` leaves the unselected part of its track looking like.
261+ private static let trackColor = Color ( uiColor: . systemFill)
262+ }
263+
50264/// Hosts the SwiftUI slider inside the UIKit view hierarchy Fabric mounts.
51265///
52266/// The hosting controller is parented to the closest view controller once this
@@ -79,11 +293,47 @@ public final class RNCSliderView: UIView {
79293 }
80294 }
81295
296+ /// Whether the slider selects a span of the range with two thumbs, rather
297+ /// than a single value with one. `value` and `valueLeft`/`valueRight` belong
298+ /// to the two shapes respectively, and the unused pair is simply not drawn.
299+ @objc public var ranged : Bool {
300+ get { model. ranged }
301+ set { model. ranged = newValue }
302+ }
303+
304+ /// Setting either of these while the matching thumb is being dragged is a
305+ /// no-op - see `RNCSliderModel.leftDragOrigin`.
306+ @objc public var valueLeft : Double {
307+ get { model. valueLeft }
308+ set {
309+ guard model. leftDragOrigin == nil else { return }
310+ model. valueLeft = newValue
311+ }
312+ }
313+
314+ @objc public var valueRight : Double {
315+ get { model. valueRight }
316+ set {
317+ guard model. rightDragOrigin == nil else { return }
318+ model. valueRight = newValue
319+ }
320+ }
321+
82322 @objc public var onValueChange : ( ( Double ) -> Void ) ? {
83323 get { model. onValueChange }
84324 set { model. onValueChange = newValue }
85325 }
86326
327+ @objc public var onLeftValueChange : ( ( Double ) -> Void ) ? {
328+ get { model. onLeftValueChange }
329+ set { model. onLeftValueChange = newValue }
330+ }
331+
332+ @objc public var onRightValueChange : ( ( Double ) -> Void ) ? {
333+ get { model. onRightValueChange }
334+ set { model. onRightValueChange = newValue }
335+ }
336+
87337 public override init ( frame: CGRect ) {
88338 let model = RNCSliderModel ( )
89339 self . model = model
@@ -113,9 +363,13 @@ public final class RNCSliderView: UIView {
113363 /// `Slider` has no width of its own to report, and flexbox is what actually
114364 /// stretches it.
115365 ///
366+ /// A ranged slider is deliberately measured the same way, from the single
367+ /// thumb: it is laid out inside whatever height it is given, and taking the
368+ /// platform's own slider height keeps the two the same size.
369+ ///
116370 /// Must be called on the main thread.
117371 @objc public static func measuredIntrinsicSize( ) -> CGSize {
118- let controller = UIHostingController ( rootView: RNCSliderContent ( model: RNCSliderModel ( ) ) )
372+ let controller = UIHostingController ( rootView: RNCSingleSliderContent ( model: RNCSliderModel ( ) ) )
119373 let fitted = controller. sizeThatFits (
120374 in: CGSize ( width: intrinsicWidth, height: . greatestFiniteMagnitude)
121375 )
@@ -138,6 +392,8 @@ public final class RNCSliderView: UIView {
138392 /// retired mid-drag would otherwise stay deaf to updates for good.
139393 @objc public func cancelSliding( ) {
140394 model. isSliding = false
395+ model. leftDragOrigin = nil
396+ model. rightDragOrigin = nil
141397 }
142398
143399 public override func layoutSubviews( ) {
0 commit comments