Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3050,12 +3050,12 @@ SkipUI fully supports SwiftUI's various layout mechanisms, including `HStack`, `
- Expanding elements such as `Spacer` or `.frame(maxWidth: .infinity)` within nested `HStacks` or `VStacks` may measure differently. Try un-nesting stacks to get more SwiftUI-like layout.
- Views with `.frame(maxWidth:)` or `.frame(maxHeight:)` set to explicit values larger than the parent's actual size may expand beyond the parent container's bounds on Android. To work around this, use a `GeometryReader` to compute the parent's size and set an explicit `.frame(width:)` or `.frame(height:)` instead of `maxWidth` or `maxHeight`. See [Issue #339](https://github.com/skiptools/skip-ui/issues/339).

Note: if your app was developed under an earlier version of Skip and it relies on nuances of older layout behavior, you can apply the Android-only `.layoutImplementationVersion()` modifier. Set this modifier on a `View` hierarchy to simulate the previous behavior:
Note: if your app was developed under an earlier version of Skip and it relies on nuances of older layout behavior, you can apply the Android-only `.layoutImplementationVersion()` modifier. Set this modifier on a `View` hierarchy to simulate the previous behavior. The current default is `3` (`NavigationStack` uses a Scaffold-style `SubcomposeLayout` for top/bottom bars). Use `2` for the previous `NavigationStack` Box overlay layout, or `0`/`1` for older stack spacing behavior:

```swift
SomeRootView()
#if os(Android)
.layoutImplementationVersion(0)
.layoutImplementationVersion(2)
#endif
```

Expand Down
37 changes: 24 additions & 13 deletions Sources/SkipUI/SkipUI/Compose/ComposeExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#if SKIP
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.ime
Expand Down Expand Up @@ -113,19 +114,6 @@ extension Modifier {
}
}

/// Add padding equivalent to the given safe area.
@Composable func padding(safeArea: SafeArea) -> Modifier {
let density = LocalDensity.current
let layoutDirection = LocalLayoutDirection.current
let top = with(density) { (safeArea.safeBoundsPx.top - safeArea.presentationBoundsPx.top).toDp() }
let left = with(density) { (safeArea.safeBoundsPx.left - safeArea.presentationBoundsPx.left).toDp() }
let bottom = with(density) { (safeArea.presentationBoundsPx.bottom - safeArea.safeBoundsPx.bottom).toDp() }
let right = with(density) { (safeArea.presentationBoundsPx.right - safeArea.safeBoundsPx.right).toDp() }
let start = layoutDirection == androidx.compose.ui.unit.LayoutDirection.Rtl ? right : left
let end = layoutDirection == androidx.compose.ui.unit.LayoutDirection.Rtl ? left : right
return self.padding(top: top, start: start, bottom: bottom, end: end)
}

/// Invoke the given closure with the modified view's root bounds.
@Composable func onGloballyPositionedInRoot(perform: (Rect) -> Void) -> Modifier {
return self.onGloballyPositioned {
Expand Down Expand Up @@ -161,6 +149,29 @@ extension Modifier {
}
}

/// Create fixed insets using logical leading and trailing values.
@Composable func contentWindowInsets(top: Dp = 0.dp, leading: Dp = 0.dp, bottom: Dp = 0.dp, trailing: Dp = 0.dp) -> WindowInsets {
let isRTL = LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl
let left = isRTL ? trailing : leading
let right = isRTL ? leading : trailing
return WindowInsets(left, top, right, bottom)
}

/// Convert WindowInsets to SwiftUI EdgeInsets at the current density and layout direction.
@Composable func edgeInsets(from windowInsets: WindowInsets) -> EdgeInsets {
let values = windowInsets.asPaddingValues()
let layoutDirection = LocalLayoutDirection.current
let left = values.calculateLeftPadding(layoutDirection)
let right = values.calculateRightPadding(layoutDirection)
let isRTL = layoutDirection == androidx.compose.ui.unit.LayoutDirection.Rtl
return EdgeInsets(
top: Double(values.calculateTopPadding().value),
leading: Double((isRTL ? right : left).value),
bottom: Double(values.calculateBottomPadding().value),
trailing: Double((isRTL ? left : right).value)
)
}

extension PaddingValues {
/// Convert padding values to edge insets in `dp` units.
@Composable public func asEdgeInsets() -> EdgeInsets {
Expand Down
63 changes: 31 additions & 32 deletions Sources/SkipUI/SkipUI/Compose/ComposeLayouts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Constraints
Expand Down Expand Up @@ -160,14 +161,6 @@ private func flexibleLayoutFloat(_ value: CGFloat?) -> Float? {
/// passed to the given closure.
/// - Parameter logTag: When non-empty, emits Android ``Log`` lines with tag `SkipUI.ISAL.<logTag>` (e.g. filter logcat `SkipUI.ISAL.List`).
@Composable func IgnoresSafeAreaLayout(expandInto: Edge.Set, checkEdges: Edge.Set = [], modifier: Modifier = Modifier, logTag: String = "", target: @Composable (IntRect, Edge.Set) -> Void) {
guard let safeArea = EnvironmentValues.shared._safeArea else {
if !logTag.isEmpty {
Log.d("SkipUI.ISAL.\(logTag)", "no SafeArea in environment; skipping expansion")
}
target(IntRect.Zero, [])
return
}

if !logTag.isEmpty {
LaunchedEffect(logTag, expandInto.rawValue, checkEdges.rawValue) {
Log.d("SkipUI.ISAL.\(logTag)", "init expandInto=\(expandInto) checkEdges=\(checkEdges) edgesState(initial)=\(checkEdges)")
Expand All @@ -179,48 +172,54 @@ private func flexibleLayoutFloat(_ value: CGFloat?) -> Float? {
// state to our output to ensure we aren't re-calling the target block when output hasn't changed
let edgesState = remember { mutableStateOf(checkEdges) }
let edges = edgesState.value
let insets = edgeInsets(from: EnvironmentValues.shared._contentWindowInsets)
let density = LocalDensity.current
let topInsetPx = with(density) { insets.top.dp.roundToPx() }
let bottomInsetPx = with(density) { insets.bottom.dp.roundToPx() }
let leadingInsetPx = with(density) { insets.leading.dp.roundToPx() }
let trailingInsetPx = with(density) { insets.trailing.dp.roundToPx() }
var expansionTop = 0
if expandInto.contains(Edge.Set.top) && edges.contains(Edge.Set.top) {
expansionTop = Int(safeArea.safeBoundsPx.top - safeArea.presentationBoundsPx.top)
expansionTop = topInsetPx
}
var expansionBottom = 0
if expandInto.contains(Edge.Set.bottom) && edges.contains(Edge.Set.bottom) {
expansionBottom = Int(safeArea.presentationBoundsPx.bottom - safeArea.safeBoundsPx.bottom)
expansionBottom = bottomInsetPx
}
var expansionLeft = 0
var expansionRight = 0
let isRTL = LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl
if isRTL {
if expandInto.contains(Edge.Set.leading) && edges.contains(Edge.Set.leading) {
expansionRight = Int(safeArea.presentationBoundsPx.right - safeArea.safeBoundsPx.right)
expansionRight = leadingInsetPx
}
if expandInto.contains(Edge.Set.trailing) && edges.contains(Edge.Set.trailing) {
expansionLeft = Int(safeArea.safeBoundsPx.left - safeArea.presentationBoundsPx.left)
expansionLeft = trailingInsetPx
}
} else {
if expandInto.contains(Edge.Set.leading) && edges.contains(Edge.Set.leading) {
expansionLeft = Int(safeArea.safeBoundsPx.left - safeArea.presentationBoundsPx.left)
expansionLeft = leadingInsetPx
}
if expandInto.contains(Edge.Set.trailing) && edges.contains(Edge.Set.trailing) {
expansionRight = Int(safeArea.presentationBoundsPx.right - safeArea.safeBoundsPx.right)
expansionRight = trailingInsetPx
}
}

var (safeLeft, safeTop, safeRight, safeBottom) = safeArea.safeBoundsPx
safeLeft -= expansionLeft
safeTop -= expansionTop
safeRight += expansionRight
safeBottom += expansionBottom

let contentSafeBounds = Rect(top: safeTop, left: safeLeft, bottom: safeBottom, right: safeRight)
let contentSafeArea = SafeArea(presentation: safeArea.presentationBoundsPx, safe: contentSafeBounds, absoluteSystemBars: safeArea.absoluteSystemBarEdges)
let contentInsets = contentWindowInsets(
top: expansionTop > 0 ? 0.dp : insets.top.dp,
leading: (isRTL ? expansionRight : expansionLeft) > 0 ? 0.dp : insets.leading.dp,
bottom: expansionBottom > 0 ? 0.dp : insets.bottom.dp,
trailing: (isRTL ? expansionLeft : expansionRight) > 0 ? 0.dp : insets.trailing.dp
)
EnvironmentValues.shared.setValues {
$0.set_safeArea(contentSafeArea)
$0.set_contentWindowInsets(contentInsets)
return ComposeResult.ok
} in: {
Layout(modifier: modifier.onGloballyPositionedInWindow {
Layout(modifier: modifier.onGloballyPositioned { coordinates in
let probeEdges = expandInto.union(checkEdges)
let newEdges = adjacentSafeAreaEdges(bounds: $0, safeArea: safeArea, isRTL: isRTL, checkEdges: probeEdges)
let bounds = coordinates.boundsInWindow()
let parentBounds = coordinates.parentLayoutCoordinates?.boundsInWindow() ?? bounds
let newEdges = adjacentSafeAreaEdges(bounds: bounds, parentBounds: parentBounds, isRTL: isRTL, checkEdges: probeEdges)
if !logTag.isEmpty {
let previous = edgesState.value
if newEdges != previous {
Expand Down Expand Up @@ -255,26 +254,26 @@ private func flexibleLayoutFloat(_ value: CGFloat?) -> Float? {
}
}

private func adjacentSafeAreaEdges(bounds: Rect, safeArea: SafeArea, isRTL: Bool, checkEdges: Edge.Set) -> Edge.Set {
private func adjacentSafeAreaEdges(bounds: Rect, parentBounds: Rect, isRTL: Bool, checkEdges: Edge.Set) -> Edge.Set {
var edges: Edge.Set = []
if checkEdges.contains(Edge.Set.top), bounds.top <= safeArea.safeBoundsPx.top + 0.1 {
if checkEdges.contains(Edge.Set.top), bounds.top <= parentBounds.top + 0.1 {
edges.insert(Edge.Set.top)
}
if checkEdges.contains(Edge.Set.bottom), bounds.bottom >= safeArea.safeBoundsPx.bottom - 0.1 {
if checkEdges.contains(Edge.Set.bottom), bounds.bottom >= parentBounds.bottom - 0.1 {
edges.insert(Edge.Set.bottom)
}
if isRTL {
if checkEdges.contains(Edge.Set.leading), bounds.right >= safeArea.safeBoundsPx.right - 0.1 {
if checkEdges.contains(Edge.Set.leading), bounds.right >= parentBounds.right - 0.1 {
edges.insert(Edge.Set.leading)
}
if checkEdges.contains(Edge.Set.trailing), bounds.left <= safeArea.safeBoundsPx.left + 0.1 {
if checkEdges.contains(Edge.Set.trailing), bounds.left <= parentBounds.left + 0.1 {
edges.insert(Edge.Set.trailing)
}
} else {
if checkEdges.contains(Edge.Set.leading), bounds.left <= safeArea.safeBoundsPx.left + 0.1 {
if checkEdges.contains(Edge.Set.leading), bounds.left <= parentBounds.left + 0.1 {
edges.insert(Edge.Set.leading)
}
if checkEdges.contains(Edge.Set.trailing), bounds.right >= safeArea.safeBoundsPx.right - 0.1 {
if checkEdges.contains(Edge.Set.trailing), bounds.right >= parentBounds.right - 0.1 {
edges.insert(Edge.Set.trailing)
}
}
Expand Down
3 changes: 1 addition & 2 deletions Sources/SkipUI/SkipUI/Containers/List.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,8 @@ public final class List : View, Renderable {
let itemContext = context.content()

// When we layout, extend into safe areas that are due to system bars, not into any app chrome
let safeArea = EnvironmentValues.shared._safeArea
var ignoresSafeAreaEdges: Edge.Set = [.top, .bottom]
ignoresSafeAreaEdges.formIntersection(safeArea?.absoluteSystemBarEdges ?? [])
ignoresSafeAreaEdges.formIntersection(EnvironmentValues.shared._presentationSystemBarEdges)
ComposeContainer(scrollAxes: .vertical, modifier: context.modifier, fillWidth: true, fillHeight: true, then: Modifier.background(BackgroundColor(styling: styling, isItem: false))) { modifier in
IgnoresSafeAreaLayout(expandInto: ignoresSafeAreaEdges, checkEdges: [.bottom], modifier: modifier, logTag: "List") { safeAreaExpansion, safeAreaEdges in
var containerModifier: Modifier
Expand Down
Loading
Loading