Skip to content

Commit 1f8ca72

Browse files
Support vertical and horizontal orientation
1 parent 2a29256 commit 1f8ca72

10 files changed

Lines changed: 216 additions & 33 deletions

File tree

package/android/src/main/java/com/reactnativecommunity/slider/RNCSliderView.kt

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import android.content.Context
44
import android.widget.FrameLayout
55
import androidx.compose.foundation.gestures.awaitEachGesture
66
import androidx.compose.foundation.gestures.awaitFirstDown
7+
import androidx.compose.foundation.layout.Box
8+
import androidx.compose.foundation.layout.fillMaxSize
79
import androidx.compose.foundation.layout.fillMaxWidth
810
import androidx.compose.material3.RangeSlider
911
import androidx.compose.material3.Slider
@@ -13,11 +15,16 @@ import androidx.compose.runtime.mutableDoubleStateOf
1315
import androidx.compose.runtime.mutableFloatStateOf
1416
import androidx.compose.runtime.mutableStateOf
1517
import androidx.compose.runtime.setValue
18+
import androidx.compose.ui.Alignment
1619
import androidx.compose.ui.Modifier
20+
import androidx.compose.ui.graphics.TransformOrigin
21+
import androidx.compose.ui.graphics.graphicsLayer
1722
import androidx.compose.ui.input.pointer.PointerEventPass
1823
import androidx.compose.ui.input.pointer.pointerInput
24+
import androidx.compose.ui.layout.layout
1925
import androidx.compose.ui.platform.ComposeView
2026
import androidx.compose.ui.platform.ViewCompositionStrategy
27+
import androidx.compose.ui.unit.Constraints
2128
import kotlin.math.abs
2229
import kotlin.math.floor
2330
import kotlin.math.pow
@@ -70,6 +77,16 @@ class RNCSliderView(context: Context) : FrameLayout(context) {
7077
private var lowerLimitValue by mutableFloatStateOf(DEFAULT_LOWER_LIMIT)
7178
private var upperLimitValue by mutableFloatStateOf(DEFAULT_UPPER_LIMIT)
7279

80+
/**
81+
* Whether the slider is laid out along the view's height rather than its width,
82+
* with the larger values at the top - see [sliderAxis].
83+
*
84+
* Kept as the one thing the orientation actually decides, rather than as the
85+
* string it arrives as, so that anything other than "vertical" reads as the
86+
* horizontal slider it defaults to.
87+
*/
88+
private var isVertical by mutableStateOf(DEFAULT_VERTICAL)
89+
7390
/**
7491
* Whether the user has hold of the slider. It lasts from the touch going down
7592
* to the last finger coming off, so it covers a drag that never moved a thumb
@@ -167,6 +184,10 @@ class RNCSliderView(context: Context) : FrameLayout(context) {
167184
upperLimitValue = value.toFloat()
168185
}
169186

187+
fun setOrientation(value: String?) {
188+
isVertical = value == ORIENTATION_VERTICAL
189+
}
190+
170191
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
171192
if (!isAttachedToWindow) {
172193
// Measuring the child here would make Compose look for a window recomposer
@@ -236,13 +257,60 @@ class RNCSliderView(context: Context) : FrameLayout(context) {
236257

237258
@Composable
238259
private fun SliderContent() {
239-
if (selectsRange) {
240-
RangedSliderContent()
241-
} else {
242-
SingleSliderContent()
260+
// The slider is only ever as thick as the control itself, and a vertical one
261+
// is far narrower than the view is wide, so it sits in the middle of whatever
262+
// room JS has given it rather than against one edge.
263+
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
264+
if (selectsRange) {
265+
RangedSliderContent()
266+
} else {
267+
SingleSliderContent()
268+
}
243269
}
244270
}
245271

272+
/**
273+
* Lays the slider out along the axis the orientation asks for, stretched across
274+
* the view.
275+
*
276+
* Material 3 has no vertical slider, so a vertical one here is its horizontal
277+
* slider turned a quarter turn anticlockwise, which puts the larger values at
278+
* the top. Everything the rotation encloses is the same control as ever - the
279+
* range and the step, both thumbs of a ranged slider, the gestures [slidingGestures]
280+
* listens to and the accessibility the platform's own slider comes with - it is
281+
* only measured and drawn the other way round.
282+
*/
283+
private fun Modifier.sliderAxis(): Modifier =
284+
if (!isVertical) {
285+
fillMaxWidth()
286+
} else {
287+
graphicsLayer {
288+
rotationZ = -90f
289+
// Turned about the top left corner, so that the slider ends up in the
290+
// box the layout below reports rather than somewhere off the side of it.
291+
transformOrigin = TransformOrigin(0f, 0f)
292+
}
293+
.layout { measurable, constraints ->
294+
// Inside the rotation the slider is still a horizontal control, so it is
295+
// offered the height it has to span as its width, and the other way round.
296+
val placeable =
297+
measurable.measure(
298+
Constraints(
299+
minWidth = constraints.minHeight,
300+
maxWidth = constraints.maxHeight,
301+
minHeight = constraints.minWidth,
302+
maxHeight = constraints.maxWidth,
303+
)
304+
)
305+
306+
// Turning anticlockwise about the corner carries the slider straight up
307+
// out of the view, so it is laid out one width to the left of it to come
308+
// back down into place - taking up a box with its two sides swapped.
309+
layout(placeable.height, placeable.width) { placeable.place(-placeable.width, 0) }
310+
}
311+
.fillMaxWidth()
312+
}
313+
246314
@Composable
247315
private fun SingleSliderContent() {
248316
val range = valueRange()
@@ -252,7 +320,7 @@ class RNCSliderView(context: Context) : FrameLayout(context) {
252320
value = sliderValue.coerceIn(range.start, range.endInclusive),
253321
valueRange = range,
254322
onValueChange = { moved -> onSliderValueChange(moved, limits) },
255-
modifier = Modifier.fillMaxWidth().slidingGestures(),
323+
modifier = Modifier.sliderAxis().slidingGestures(),
256324
)
257325
}
258326

@@ -343,7 +411,7 @@ class RNCSliderView(context: Context) : FrameLayout(context) {
343411
value = selected,
344412
valueRange = range,
345413
onValueChange = { moved -> onSelectedRangeChange(from = selected, to = moved, limits = limits) },
346-
modifier = Modifier.fillMaxWidth().slidingGestures(),
414+
modifier = Modifier.sliderAxis().slidingGestures(),
347415
)
348416
}
349417

@@ -397,6 +465,10 @@ class RNCSliderView(context: Context) : FrameLayout(context) {
397465
const val DEFAULT_STEP = 0.0
398466
const val DEFAULT_LOWER_LIMIT = 0f
399467
const val DEFAULT_UPPER_LIMIT = 1f
468+
const val DEFAULT_VERTICAL = false
469+
470+
/** The one orientation that is not the default. */
471+
private const val ORIENTATION_VERTICAL = "vertical"
400472
}
401473
}
402474

package/android/src/main/java/com/reactnativecommunity/slider/RNCSliderViewManager.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ class RNCSliderViewManager :
7272
view.setUpperLimit(value)
7373
}
7474

75+
@ReactProp(name = "orientation")
76+
override fun setOrientation(view: RNCSliderView, value: String?) {
77+
view.setOrientation(value)
78+
}
79+
7580
override fun addEventEmitters(reactContext: ThemedReactContext, view: RNCSliderView) {
7681
@Suppress("DEPRECATION")
7782
val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, view.id)
@@ -121,6 +126,10 @@ class RNCSliderViewManager :
121126
* Fabric asks for the intrinsic size once per surface and reuses the answer for
122127
* every slider on it, so it must not depend on the incoming constraints. The
123128
* width is only a hint - flexbox is what actually stretches the slider.
129+
*
130+
* The answer describes a horizontal slider, which is the only shape measured
131+
* here: a vertical one is the same control on its side, and its shadow node
132+
* turns this size round to match - see `RNCSliderShadowNode::measureContent`.
124133
*/
125134
override fun measure(
126135
context: Context,

package/common/cpp/react/renderer/components/RNCSlider/RNCSliderShadowNode.cpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ namespace facebook {
1919
Size RNCSliderShadowNode::measureContent(
2020
const LayoutContext & /*layoutContext*/,
2121
const LayoutConstraints &layoutConstraints) const {
22-
return measurementsManager_->measure(getSurfaceId(), layoutConstraints);
22+
return sizeForOrientation(
23+
measurementsManager_->measure(getSurfaceId(), layoutConstraints));
2324
}
2425
#endif
2526

package/common/cpp/react/renderer/components/RNCSlider/RNCSliderShadowNode.h

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,20 @@ namespace facebook {
2525
public:
2626
using ConcreteViewShadowNode::ConcreteViewShadowNode;
2727

28+
/*
29+
* The size this slider takes, given the size measured for a horizontal
30+
* one. A vertical slider is the same control turned a quarter turn, so
31+
* it stands as tall as a horizontal one is wide and as wide as one is
32+
* tall - which is why neither platform measures it a second time.
33+
*/
34+
Size sizeForOrientation(Size horizontalSize) const {
35+
if (getConcreteProps().orientation != RNCSliderOrientation::Vertical) {
36+
return horizontalSize;
37+
}
38+
39+
return {horizontalSize.height, horizontalSize.width};
40+
}
41+
2842
#ifdef ANDROID
2943
void setSliderMeasurementsManager(
3044
const std::shared_ptr<RNCSliderMeasurementsManager> &measurementsManager);

package/ios/RNCSliderComponentView.mm

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ - (void)applySliderProps:(const RNCSliderProps &)props
7777
_sliderView.step = props.step;
7878
_sliderView.lowerLimit = props.lowerLimit;
7979
_sliderView.upperLimit = props.upperLimit;
80+
_sliderView.vertical = props.orientation == RNCSliderOrientation::Vertical;
8081
}
8182

8283
- (void)emitValueChange:(double)value
@@ -163,6 +164,9 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
163164
if (oldViewProps.upperLimit != newViewProps.upperLimit) {
164165
_sliderView.upperLimit = newViewProps.upperLimit;
165166
}
167+
if (oldViewProps.orientation != newViewProps.orientation) {
168+
_sliderView.vertical = newViewProps.orientation == RNCSliderOrientation::Vertical;
169+
}
166170

167171
[super updateProps:props oldProps:oldProps];
168172
}

package/ios/RNCSliderShadowNode.mm

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@
2323
// Layout runs off the main thread, and SwiftUI can only be asked for a size on
2424
// it. Every slider gets the same answer, so pay for the hop once. This mirrors
2525
// `RCTSwitchSize()`, which React Native uses to size <Switch> the same way.
26+
//
27+
// The answer describes a horizontal slider; a vertical one is the same control
28+
// on its side, and `sizeForOrientation` turns the size round rather than asking
29+
// SwiftUI for a second one.
2630
static CGSize intrinsicSize;
2731
static dispatch_once_t onceToken;
2832
dispatch_once(&onceToken, ^{
@@ -31,10 +35,10 @@
3135
});
3236
});
3337

34-
return layoutConstraints.clamp({
38+
return layoutConstraints.clamp(sizeForOrientation({
3539
.width = static_cast<Float>(intrinsicSize.width),
3640
.height = static_cast<Float>(intrinsicSize.height),
37-
});
41+
}));
3842
}
3943

4044
} // namespace facebook::react

0 commit comments

Comments
 (0)