diff --git a/Sources/SkipUI/SkipUI/Compose/ComposeExtensions.swift b/Sources/SkipUI/SkipUI/Compose/ComposeExtensions.swift index c035bd4c..63005223 100644 --- a/Sources/SkipUI/SkipUI/Compose/ComposeExtensions.swift +++ b/Sources/SkipUI/SkipUI/Compose/ComposeExtensions.swift @@ -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() @@ -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 diff --git a/Sources/SkipUI/SkipUI/Containers/PresentationRoot.swift b/Sources/SkipUI/SkipUI/Containers/PresentationRoot.swift index 3e7461e0..b46316b3 100644 --- a/Sources/SkipUI/SkipUI/Containers/PresentationRoot.swift +++ b/Sources/SkipUI/SkipUI/Containers/PresentationRoot.swift @@ -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 { diff --git a/Sources/SkipUI/SkipUI/Layout/GeometryReader.swift b/Sources/SkipUI/SkipUI/Layout/GeometryReader.swift index 2828d3cb..e104ddf8 100644 --- a/Sources/SkipUI/SkipUI/Layout/GeometryReader.swift +++ b/Sources/SkipUI/SkipUI/Layout/GeometryReader.swift @@ -25,8 +25,12 @@ public struct GeometryReader : View, Renderable { #if SKIP @Composable override func Render(context: ComposeContext) { let rememberedGlobalFramePx = remember { mutableStateOf(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) diff --git a/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift b/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift index 6899f2e9..1609b36b 100644 --- a/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift +++ b/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift @@ -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) })