Skip to content

Commit d22d46f

Browse files
authored
Merge pull request #238 from skiptools/stacklayouts2
More SwiftUI-like layout
2 parents 338c85b + 038ebec commit d22d46f

18 files changed

+2066
-227
lines changed

Sources/SkipUI/Skip/StackLayouts.kt

Lines changed: 1566 additions & 0 deletions
Large diffs are not rendered by default.

Sources/SkipUI/SkipUI/Commands/Toolbar.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#if !SKIP_BRIDGE
44
#if SKIP
55
import androidx.compose.foundation.gestures.ScrollableState
6+
import androidx.compose.foundation.layout.fillMaxWidth
67
import androidx.compose.foundation.layout.width
78
import androidx.compose.runtime.Composable
89
import androidx.compose.ui.Modifier
@@ -189,7 +190,7 @@ public struct ToolbarSpacer : ToolbarContent, CustomizableToolbarContent, View,
189190
if sizing == .fixed {
190191
modifier = Modifier.width(8.dp)
191192
} else {
192-
modifier = EnvironmentValues.shared._fillWidth?() ?? Modifier
193+
modifier = EnvironmentValues.shared._flexibleWidth?(nil, nil, Float.flexibleSpace) ?? Modifier
193194
}
194195
androidx.compose.foundation.layout.Spacer(modifier: modifier)
195196
}

Sources/SkipUI/SkipUI/Components/ProgressView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ public struct ProgressView : View, Renderable {
107107
}
108108

109109
@Composable private func RenderLinearProgress(context: ComposeContext) {
110-
let modifier = Modifier.fillWidth().then(context.modifier)
110+
let modifier = Modifier.flexibleWidth().then(context.modifier)
111111
let color = EnvironmentValues.shared._tint?.colorImpl() ?? ProgressIndicatorDefaults.linearColor
112112
if value == nil || total == nil {
113113
LinearProgressIndicator(modifier: modifier, color: color)

Sources/SkipUI/SkipUI/Components/Spacer.swift

Lines changed: 49 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception
33
#if !SKIP_BRIDGE
44
#if SKIP
5+
import androidx.compose.foundation.layout.fillMaxSize
56
import androidx.compose.foundation.layout.height
67
import androidx.compose.foundation.layout.width
78
import androidx.compose.runtime.Composable
@@ -11,44 +12,70 @@ import androidx.compose.ui.unit.dp
1112
import struct CoreGraphics.CGFloat
1213
#endif
1314

15+
// We use a class rather than struct to be able to mutate the `positionalMinLength` for layout.
1416
// SKIP @bridge
15-
public struct Spacer : View, Renderable {
16-
private let minLength: CGFloat?
17+
public final class Spacer : View, Renderable {
18+
let minLength: CGFloat?
1719

1820
// SKIP @bridge
1921
public init(minLength: CGFloat? = nil) {
2022
self.minLength = minLength
2123
}
2224

2325
#if SKIP
24-
@Composable override func Render(context: ComposeContext) {
25-
// We haven't found a way that works to get a minimum size and expanding behavior on a spacer, so use two spacers: the
26-
// first to enforce the minimum, and the second to expand. Note that this will cause some modifiers to behave incorrectly
26+
/// When we layout an `HStack` or `VStack` we apply a positional min length to spacers between elements.
27+
var positionalMinLength: CGFloat?
2728

29+
@Composable override func Render(context: ComposeContext) {
30+
let layoutImplementationVersion = EnvironmentValues.shared._layoutImplementationVersion
2831
let axis = EnvironmentValues.shared._layoutAxis
29-
if let minLength, minLength > 0.0 {
30-
let minModifier: Modifier
32+
let effectiveMinLength = minLength ?? positionalMinLength
33+
let minLengthFloat: Float? = effectiveMinLength != nil && effectiveMinLength! > 0.0 ? Float(effectiveMinLength!) : nil
34+
if layoutImplementationVersion == 0 {
35+
// Maintain previous layout behavior for users who opt in
36+
if let minLengthFloat {
37+
let minModifier: Modifier
38+
switch axis {
39+
case .horizontal:
40+
minModifier = Modifier.width(minLengthFloat.dp)
41+
case .vertical:
42+
minModifier = Modifier.height(minLengthFloat.dp)
43+
case nil:
44+
minModifier = Modifier
45+
}
46+
androidx.compose.foundation.layout.Spacer(modifier: minModifier.then(context.modifier))
47+
}
48+
49+
let fillModifier: Modifier
3150
switch axis {
3251
case .horizontal:
33-
minModifier = Modifier.width(minLength.dp)
52+
fillModifier = EnvironmentValues.shared._flexibleWidth?(nil, nil, Float.flexibleSpace) ?? Modifier
3453
case .vertical:
35-
minModifier = Modifier.height(minLength.dp)
54+
fillModifier = EnvironmentValues.shared._flexibleHeight?(nil, nil, Float.flexibleSpace) ?? Modifier
3655
case nil:
37-
minModifier = Modifier
56+
fillModifier = Modifier
3857
}
39-
androidx.compose.foundation.layout.Spacer(modifier: minModifier.then(context.modifier))
40-
}
41-
42-
let fillModifier: Modifier
43-
switch axis {
44-
case .horizontal:
45-
fillModifier = EnvironmentValues.shared._fillWidth?() ?? Modifier
46-
case .vertical:
47-
fillModifier = EnvironmentValues.shared._fillHeight?() ?? Modifier
48-
case nil:
49-
fillModifier = Modifier
58+
androidx.compose.foundation.layout.Spacer(modifier: fillModifier.then(context.modifier))
59+
} else {
60+
let modifier: Modifier
61+
switch axis {
62+
case .horizontal:
63+
if let flexibleWidth = EnvironmentValues.shared._flexibleWidth {
64+
modifier = flexibleWidth(nil, minLengthFloat, Float.flexibleSpace)
65+
} else {
66+
modifier = Modifier
67+
}
68+
case .vertical:
69+
if let flexibleHeight = EnvironmentValues.shared._flexibleHeight {
70+
modifier = flexibleHeight(nil, minLengthFloat, Float.flexibleSpace)
71+
} else {
72+
modifier = Modifier
73+
}
74+
case nil:
75+
modifier = Modifier.fillMaxSize()
76+
}
77+
androidx.compose.foundation.layout.Spacer(modifier: modifier.then(context.modifier))
5078
}
51-
androidx.compose.foundation.layout.Spacer(modifier: fillModifier.then(context.modifier))
5279
}
5380
#else
5481
public var body: some View {

Sources/SkipUI/SkipUI/Compose/ComposeContainer.swift

Lines changed: 76 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -17,55 +17,67 @@ import androidx.compose.ui.Modifier
1717

1818
/// Composable to handle sizing and layout in a SwiftUI-like way for containers that compose child content.
1919
///
20-
/// Compose's behavior differs from SwiftUI's when dealing with filling space. A SwiftUI container will give each child the
21-
/// space it needs to display, then automatically divide the remainder between children that want to expand. In Compose, on
22-
/// the other hand, a single 'fillMaxWidth' child will consume all remaining space, pushing subsequent children out. To get
23-
/// SwiftUI's behavior, all children that want to expand must use the `weight` modifier, which is only available in a Row or
24-
/// Column scope. We've abstracted the fact that 'weight' for a given dimension may or may not be available depending on scope
25-
/// behind our `Modifier.fillWidth` and `Modifier.fillHeight` extension functions.
20+
/// - Seealso: `ComposeFlexibleContainer(...)`
21+
@Composable public func ComposeContainer(axis: Axis? = nil, eraseAxis: Bool = false, scrollAxes: Axis.Set = [], modifier: Modifier = Modifier, fixedWidth: Bool = false, fillWidth: Bool = false, fixedHeight: Bool = false, fillHeight: Bool = false, then: Modifier = Modifier, content: @Composable (Modifier) -> Void) {
22+
ComposeFlexibleContainer(axis: axis, eraseAxis: eraseAxis, scrollAxes: scrollAxes, modifier: modifier, fixedWidth: fixedWidth, flexibleWidthMax: fillWidth ? Float.flexibleFill : nil, fixedHeight: fixedHeight, flexibleHeightMax: fillHeight ? Float.flexibleFill : nil, then: then, content: content)
23+
}
24+
25+
/// Composable to handle sizing and layout in a SwiftUI-like way for containers that compose child content.
26+
///
27+
/// In Compose, containers are not perfectly layout neutral. A container that wants to expand must use the proper
28+
/// modifier, rather than relying on its content. Additionally, a single 'fillMaxWidth' child will consume all
29+
/// remaining space, pushing subsequent children out.
2630
///
27-
/// Having to explicitly set a certain modifier in order to expand within a parent is problematic for containers that want to
28-
/// fit content. The container only wants to expand if it has content that wants to expand. It can't know this until it composes
29-
/// its content. The code in this function sets triggers on the environment values that we use in 'fillWidth' and 'fillHeight' so
30-
/// that if the container content uses them, the container itself can recompose with the appropriate expansion to match its
31-
/// content. Note that this generally only affects final layout when an expanding child is in a container that is itself in a
32-
/// container, and it has to share space with other members of the parent container.
33-
@Composable public func ComposeContainer(axis: Axis? = nil, eraseAxis: Bool = false, scrollAxes: Axis.Set = [], modifier: Modifier = Modifier, fillWidth: Bool = false, fixedWidth: Bool = false, minWidth: Bool = false, fillHeight: Bool = false, fixedHeight: Bool = false, minHeight: Bool = false, then: Modifier = Modifier, content: @Composable (Modifier) -> Void) {
34-
// Use remembered expansion values to recompose on change
35-
let isFillWidth = remember { mutableStateOf(fillWidth) }
36-
let isFillHeight = remember { mutableStateOf(fillHeight) }
31+
/// Having to explicitly set a modifier in order to expand within a parent in Compose is problematic for containers that
32+
/// want to fit content. The container only wants to expand if it has content that wants to expand. It can't know this
33+
/// until it composes its content. The code in this function sets triggers on the environment values that we use in
34+
/// flexible layout so that if the container content uses them, the container itself can recompose with the appropriate
35+
/// expansion to match its content. Note that this generally only affects final layout when an expanding child is in a
36+
/// container that is itself in a container, and it has to share space with other members of the parent container.
37+
@Composable public func ComposeFlexibleContainer(axis: Axis? = nil, eraseAxis: Bool = false, scrollAxes: Axis.Set = [], modifier: Modifier = Modifier, fixedWidth: Bool = false, flexibleWidthIdeal: Float? = nil, flexibleWidthMin: Float? = nil, flexibleWidthMax: Float? = nil, fixedHeight: Bool = false, flexibleHeightIdeal: Float? = nil, flexibleHeightMin: Float? = nil, flexibleHeightMax: Float? = nil, then: Modifier = Modifier, content: @Composable (Modifier) -> Void) {
38+
// Use remembered flexible values to recompose on change
39+
let contentFlexibleWidthMax = remember { mutableStateOf(flexibleWidthMax) }
40+
let contentFlexibleHeightMax = remember { mutableStateOf(flexibleHeightMax) }
3741

38-
// Create the correct modifier for the current values. We must use IntrinsicSize.Max for fills in a scroll direction
39-
// because Compose's fillMax modifiers have no effect in the scroll direction. We can't use IntrinsicSize for scrolling
40-
// containers, however
42+
// Create the correct modifier for the current values and content
4143
var modifier = modifier
4244
let inheritedLayoutScrollAxes = EnvironmentValues.shared._layoutScrollAxes
4345
var totalLayoutScrollAxes = inheritedLayoutScrollAxes
44-
if fixedWidth || minWidth || axis == .vertical {
46+
if fixedWidth || flexibleWidthMax?.isFlexibleNonExpandingMax == true || flexibleWidthMin?.isFlexibleNonExpandingMin == true || axis == .vertical {
4547
totalLayoutScrollAxes.remove(Axis.Set.horizontal)
4648
}
47-
if !fixedWidth && isFillWidth.value {
48-
if fillWidth {
49-
modifier = modifier.fillWidth()
50-
} else if inheritedLayoutScrollAxes.contains(Axis.Set.horizontal) {
51-
modifier = modifier.width(IntrinsicSize.Max)
49+
if !fixedWidth {
50+
if flexibleWidthMax?.isFlexibleExpanding != true && contentFlexibleWidthMax.value?.isFlexibleExpanding == true && inheritedLayoutScrollAxes.contains(Axis.Set.horizontal) {
51+
// We must use IntrinsicSize.Max for fills in a scroll direction because Compose's fillMax modifiers
52+
// have no effect in the scroll direction. Flexible values can influence intrinsic measurement
53+
let minValue = flexibleWidthMin?.isFlexibleNonExpandingMin == true ? flexibleWidthMin : nil
54+
let maxValue = flexibleWidthMax?.isFlexibleNonExpandingMax == true ? flexibleWidthMax : nil
55+
modifier = modifier.flexibleWidth(min: minValue, max: maxValue).width(IntrinsicSize.Max)
5256
} else {
53-
modifier = modifier.fillWidth()
57+
let max: Float? = flexibleWidthMax ?? contentFlexibleWidthMax.value
58+
if flexibleWidthIdeal != nil || flexibleWidthMin != nil || max != nil {
59+
modifier = modifier.flexibleWidth(ideal: flexibleWidthIdeal, min: flexibleWidthMin, max: max)
60+
}
5461
}
5562
}
56-
if fixedHeight || minHeight || axis == .horizontal {
63+
if fixedHeight || flexibleHeightMax?.isFlexibleNonExpandingMax == true || flexibleHeightMin?.isFlexibleNonExpandingMin == true || axis == .horizontal {
5764
totalLayoutScrollAxes.remove(Axis.Set.vertical)
5865
}
59-
if !fixedHeight && isFillHeight.value {
60-
if fillHeight {
61-
modifier = modifier.fillHeight()
62-
} else if inheritedLayoutScrollAxes.contains(Axis.Set.vertical) {
63-
modifier = modifier.height(IntrinsicSize.Max)
66+
if !fixedHeight {
67+
if flexibleHeightMax?.isFlexibleExpanding != true && contentFlexibleHeightMax.value?.isFlexibleExpanding == true && inheritedLayoutScrollAxes.contains(Axis.Set.vertical) {
68+
// We must use IntrinsicSize.Max for fills in a scroll direction because Compose's fillMax modifiers
69+
// have no effect in the scroll direction. Flexible values can influence intrinsic measurement
70+
let minValue = flexibleHeightMin?.isFlexibleNonExpandingMin == true ? flexibleHeightMin : nil
71+
let maxValue = flexibleHeightMax?.isFlexibleNonExpandingMax == true ? flexibleHeightMax : nil
72+
modifier = modifier.flexibleHeight(min: minValue, max: maxValue).height(IntrinsicSize.Max)
6473
} else {
65-
modifier = modifier.fillHeight()
74+
let max: Float? = flexibleHeightMax ?? contentFlexibleHeightMax.value
75+
if flexibleHeightIdeal != nil || flexibleHeightMin != nil || max != nil {
76+
modifier = modifier.flexibleHeight(ideal: flexibleHeightIdeal, min: flexibleHeightMin, max: max)
77+
}
6678
}
6779
}
68-
80+
6981
totalLayoutScrollAxes.formUnion(scrollAxes)
7082
let inheritedScrollAxes = EnvironmentValues.shared._scrollAxes
7183
let totalScrollAxes = inheritedScrollAxes.union(scrollAxes)
@@ -87,26 +99,42 @@ import androidx.compose.ui.Modifier
8799

88100
// Reset the container layout because this is a new container. A directional container like 'HStack' or 'VStack' will set
89101
// the correct layout before rendering in the content block below, so that its own children can distribute available space
90-
$0.set_fillWidthModifier(nil)
91-
$0.set_fillHeightModifier(nil)
102+
$0.set_flexibleWidthModifier(nil)
103+
$0.set_flexibleHeightModifier(nil)
92104

93-
// Set the 'fillWidth' and 'fillHeight' blocks to trigger a side effect to update our container's expansion state, which can
94-
// cause it to recompose and recalculate its own modifier. We must use `SideEffect` or the recomposition never happens
95-
$0.set_fillWidth {
96-
if !isFillWidth.value {
97-
SideEffect {
98-
isFillWidth.value = true
105+
// Set the 'flexibleWidth' and 'flexibleHeight' blocks to trigger a side effect to update our container's expansion state, which
106+
// can cause it to recompose and recalculate its own modifier. We must use `SideEffect` or the recomposition never happens
107+
$0.set_flexibleWidth { ideal, min, max in
108+
var defaultModifier: Modifier = Modifier
109+
if max?.isFlexibleExpanding == true {
110+
if max == Float.flexibleFill {
111+
SideEffect { contentFlexibleWidthMax.value = Float.flexibleFill }
112+
} else if contentFlexibleWidthMax.value != Float.flexibleFill {
113+
// max must be flexibleSpace or flexibleUnknownWithSpace
114+
SideEffect { contentFlexibleWidthMax.value = Float.flexibleUnknownWithSpace }
99115
}
116+
defaultModifier = Modifier.fillMaxWidth()
117+
} else if max != nil && contentFlexibleWidthMax.value == nil {
118+
SideEffect { contentFlexibleWidthMax.value = Float.flexibleUnknownNonExpanding }
100119
}
101-
return EnvironmentValues.shared._fillWidthModifier ?? Modifier.fillMaxWidth()
120+
return EnvironmentValues.shared._flexibleWidthModifier?(ideal, min, max)
121+
?? defaultModifier.applyNonExpandingFlexibleWidth(ideal: ideal, min: min, max: max)
102122
}
103-
$0.set_fillHeight {
104-
if !isFillHeight.value {
105-
SideEffect {
106-
isFillHeight.value = true
123+
$0.set_flexibleHeight { ideal, min, max in
124+
var defaultModifier: Modifier = Modifier
125+
if max?.isFlexibleExpanding == true {
126+
if max == Float.flexibleFill {
127+
SideEffect { contentFlexibleHeightMax.value = Float.flexibleFill }
128+
} else if contentFlexibleHeightMax.value != Float.flexibleFill {
129+
// max must be flexibleSpace or flexibleUnknownWithSpace
130+
SideEffect { contentFlexibleHeightMax.value = Float.flexibleUnknownWithSpace }
107131
}
132+
defaultModifier = Modifier.fillMaxHeight()
133+
} else if max != nil && contentFlexibleHeightMax.value == nil {
134+
SideEffect { contentFlexibleHeightMax.value = Float.flexibleUnknownNonExpanding }
108135
}
109-
return EnvironmentValues.shared._fillHeightModifier ?? Modifier.fillMaxHeight()
136+
return EnvironmentValues.shared._flexibleHeightModifier?(ideal, min, max)
137+
?? defaultModifier.applyNonExpandingFlexibleHeight(ideal: ideal, min: min, max: max)
110138
}
111139
return ComposeResult.ok
112140
} in: {

0 commit comments

Comments
 (0)