Skip to content

Commit b637550

Browse files
Add a range support (iOS)
1 parent 71a0660 commit b637550

5 files changed

Lines changed: 432 additions & 11 deletions

File tree

package/ios/RNCSliderComponentView.mm

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ - (instancetype)initWithFrame:(CGRect)frame
4747
_sliderView.onValueChange = ^(double value) {
4848
[weakSelf emitValueChange:value];
4949
};
50+
_sliderView.onLeftValueChange = ^(double value) {
51+
[weakSelf emitLeftValueChange:value];
52+
};
53+
_sliderView.onRightValueChange = ^(double value) {
54+
[weakSelf emitRightValueChange:value];
55+
};
5056

5157
self.contentView = _sliderView;
5258
}
@@ -59,6 +65,9 @@ - (void)applySliderProps:(const RNCSliderProps &)props
5965
_sliderView.minimumValue = props.minimumValue;
6066
_sliderView.maximumValue = props.maximumValue;
6167
_sliderView.value = props.value;
68+
_sliderView.ranged = props.ranged;
69+
_sliderView.valueLeft = props.valueLeft;
70+
_sliderView.valueRight = props.valueRight;
6271
}
6372

6473
- (void)emitValueChange:(double)value
@@ -71,6 +80,26 @@ - (void)emitValueChange:(double)value
7180
eventEmitter->onValueChange(RNCSliderEventEmitter::OnValueChange{.value = value});
7281
}
7382

83+
- (void)emitLeftValueChange:(double)value
84+
{
85+
const auto eventEmitter = std::static_pointer_cast<const RNCSliderEventEmitter>(_eventEmitter);
86+
if (!eventEmitter) {
87+
return;
88+
}
89+
90+
eventEmitter->onLeftValueChange(RNCSliderEventEmitter::OnLeftValueChange{.value = value});
91+
}
92+
93+
- (void)emitRightValueChange:(double)value
94+
{
95+
const auto eventEmitter = std::static_pointer_cast<const RNCSliderEventEmitter>(_eventEmitter);
96+
if (!eventEmitter) {
97+
return;
98+
}
99+
100+
eventEmitter->onRightValueChange(RNCSliderEventEmitter::OnRightValueChange{.value = value});
101+
}
102+
74103
#pragma mark - RCTComponentViewProtocol
75104

76105
- (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &)oldProps
@@ -87,6 +116,15 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
87116
if (oldViewProps.value != newViewProps.value) {
88117
_sliderView.value = newViewProps.value;
89118
}
119+
if (oldViewProps.ranged != newViewProps.ranged) {
120+
_sliderView.ranged = newViewProps.ranged;
121+
}
122+
if (oldViewProps.valueLeft != newViewProps.valueLeft) {
123+
_sliderView.valueLeft = newViewProps.valueLeft;
124+
}
125+
if (oldViewProps.valueRight != newViewProps.valueRight) {
126+
_sliderView.valueRight = newViewProps.valueRight;
127+
}
90128

91129
[super updateProps:props oldProps:oldProps];
92130
}
@@ -100,8 +138,8 @@ - (void)prepareForRecycle
100138
static const auto defaultProps = std::make_shared<const RNCSliderProps>();
101139
_props = defaultProps;
102140

103-
// A view retired mid-drag still believes it owns the thumb, and would drop the
104-
// value pushed right after.
141+
// A view retired mid-drag still believes it owns the thumb it was dragging,
142+
// and would drop the value pushed right after.
105143
[_sliderView cancelSliding];
106144
[self applySliderProps:*defaultProps];
107145
}

package/ios/RNCSliderView.swift

Lines changed: 257 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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 {
3048
struct 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

Comments
 (0)