Skip to content
Open
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
34 changes: 34 additions & 0 deletions Sources/SkipUI/SkipUI/Compose/ComposeExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@ extension Modifier {
}

/// Invoke the given closure with the modified view's root bounds.
///
/// - Warning: Callbacks can deliver bounds that differ from the previous pass by sub-pixel
/// amounts (float rounding, scroll settle, in-progress animations). Callers that gate
/// composed content on remembered bounds state should guard their state write with
/// `Rect.isApproximatelyEqual(to:)` to avoid an idle recomposition loop.
@Composable func onGloballyPositionedInRoot(perform: (Rect) -> Void) -> Modifier {
return self.onGloballyPositioned {
let bounds = $0.boundsInRoot()
Expand Down Expand Up @@ -185,4 +190,33 @@ extension PaddingValues {
}
}

/// The maximum per-edge delta at which two bounds rects are treated as visually identical.
///
/// Global-position callbacks can report bounds that differ from the previous layout pass by
/// sub-pixel amounts (float rounding, scroll settle, in-progress animations). Writing such a
/// rect into gating state closes a write → recompose → remeasure → write loop that recomposes
/// continuously while the screen is idle. Half a pixel is below anything visually meaningful.
let boundsEpsilonPx = Float(0.5)

extension Rect {
/// Whether every edge of this rect is within `boundsEpsilonPx` of `other`'s.
///
/// Use to guard remembered-bounds state writes in global-position callbacks; see
/// `onGloballyPositionedInRoot`.
func isApproximatelyEqual(to other: Rect?) -> Bool {
guard let other else {
return false
}
return isWithinEpsilon(left, other.left)
&& isWithinEpsilon(top, other.top)
&& isWithinEpsilon(right, other.right)
&& isWithinEpsilon(bottom, other.bottom)
}
}

private func isWithinEpsilon(_ a: Float, _ b: Float) -> Bool {
let delta = a - b
return delta < boundsEpsilonPx && delta > -boundsEpsilonPx
}

#endif
8 changes: 6 additions & 2 deletions Sources/SkipUI/SkipUI/Containers/PresentationRoot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,12 @@ import androidx.compose.ui.platform.LocalLayoutDirection
rootModifier = rootModifier.imePadding()
}
rootModifier = rootModifier.background(Color.background.colorImpl())
.onGloballyPositionedInWindow {
presentationBounds.value = $0
.onGloballyPositionedInWindow { bounds in
// Guard against sub-pixel jitter: the presented content below is gated on
// this state, so unfiltered writes can recompose in a loop while idle
if !bounds.isApproximatelyEqual(to: presentationBounds.value) {
presentationBounds.value = bounds
}
}
Box(modifier: rootModifier) {
guard presentationBounds.value != Rect.Zero else {
Expand Down
8 changes: 6 additions & 2 deletions Sources/SkipUI/SkipUI/Layout/GeometryReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,12 @@ public struct GeometryReader : View, Renderable {
#if SKIP
@Composable override func Render(context: ComposeContext) {
let rememberedGlobalFramePx = remember { mutableStateOf<Rect?>(nil) }
Box(modifier: context.modifier.fillSize().onGloballyPositionedInRoot {
rememberedGlobalFramePx.value = $0
Box(modifier: context.modifier.fillSize().onGloballyPositionedInRoot { bounds in
// Guard against sub-pixel jitter: the content below is gated on this state, so
// unfiltered writes can recompose in a loop while the screen is idle
if !bounds.isApproximatelyEqual(to: rememberedGlobalFramePx.value) {
rememberedGlobalFramePx.value = bounds
}
}) {
if let globalFramePx = rememberedGlobalFramePx.value {
let proxy = GeometryProxy(globalFramePx: globalFramePx, density: LocalDensity.current, safeArea: EnvironmentValues.shared._safeArea)
Expand Down
6 changes: 5 additions & 1 deletion Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1025,7 +1025,11 @@ extension View {

var updatedContext = context
updatedContext.modifier = context.modifier.onGloballyPositionedInRoot { rect in
globalFramePx.value = rect
// Guard against sub-pixel jitter: the transform/action above reads this state,
// so unfiltered writes can recompose in a loop while the screen is idle
if !rect.isApproximatelyEqual(to: globalFramePx.value) {
globalFramePx.value = rect
}
}
renderable.Render(context: updatedContext)
})
Expand Down
Loading