From 446bfd4fd95a3b27c3e1ef629d466c3a253fbf6f Mon Sep 17 00:00:00 2001 From: Aecasorg <19954019+Aecasorg@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:10:16 +0100 Subject: [PATCH 1/2] Guard geometry state writes against sub-pixel bounds jitter to prevent idle recomposition loops Global-position callbacks can deliver bounds differing from the previous layout pass by sub-pixel amounts. GeometryReader, onGeometryChangeErased, and PresentationRoot write those raw Rects into remembered state that gates their composed content, closing a write -> recompose -> remeasure -> write loop that recomposes continuously at idle (#488). Adds Rect.isApproximatelyEqual(to:) (edges within 0.5px) and guards the three gating state writes. The shared onGloballyPositionedInRoot/InWindow helpers still deliver every callback so derived-value consumers (e.g. safe-area edge probing) keep their semantics; their docs now carry a warning for bounds-gating callers. Co-Authored-By: Claude Fable 5 --- .../SkipUI/Compose/ComposeExtensions.swift | 29 +++++++++++++++++++ .../SkipUI/Containers/PresentationRoot.swift | 8 +++-- .../SkipUI/SkipUI/Layout/GeometryReader.swift | 8 +++-- .../SkipUI/View/AdditionalViewModifiers.swift | 6 +++- 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/Sources/SkipUI/SkipUI/Compose/ComposeExtensions.swift b/Sources/SkipUI/SkipUI/Compose/ComposeExtensions.swift index c035bd4c..e4a4c181 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,28 @@ 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 abs(left - other.left) < boundsEpsilonPx + && abs(top - other.top) < boundsEpsilonPx + && abs(right - other.right) < boundsEpsilonPx + && abs(bottom - other.bottom) < 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) }) From f8ec125ff58a1c183766d7fd427368842b598034 Mon Sep 17 00:00:00 2001 From: Aecasorg <19954019+Aecasorg@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:10:16 +0100 Subject: [PATCH 2/2] =?UTF-8?q?Replace=20abs()=20with=20sign-free=20Float?= =?UTF-8?q?=20comparisons=20=E2=80=94=20skip.lib=20abs=20transpiles=20to?= =?UTF-8?q?=20a=20form=20Kotlin=20cannot=20compare=20against=20Float?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../SkipUI/SkipUI/Compose/ComposeExtensions.swift | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Sources/SkipUI/SkipUI/Compose/ComposeExtensions.swift b/Sources/SkipUI/SkipUI/Compose/ComposeExtensions.swift index e4a4c181..63005223 100644 --- a/Sources/SkipUI/SkipUI/Compose/ComposeExtensions.swift +++ b/Sources/SkipUI/SkipUI/Compose/ComposeExtensions.swift @@ -207,11 +207,16 @@ extension Rect { guard let other else { return false } - return abs(left - other.left) < boundsEpsilonPx - && abs(top - other.top) < boundsEpsilonPx - && abs(right - other.right) < boundsEpsilonPx - && abs(bottom - other.bottom) < boundsEpsilonPx + 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