diff --git a/CodenameOne/src/com/codename1/components/StickyHeaderContainer.java b/CodenameOne/src/com/codename1/components/StickyHeaderContainer.java index 0814b7c3c7..33dd469a37 100644 --- a/CodenameOne/src/com/codename1/components/StickyHeaderContainer.java +++ b/CodenameOne/src/com/codename1/components/StickyHeaderContainer.java @@ -24,6 +24,7 @@ import com.codename1.ui.Component; import com.codename1.ui.Container; +import com.codename1.ui.Graphics; import com.codename1.ui.events.ScrollListener; import com.codename1.ui.geom.Dimension; import com.codename1.ui.layouts.BorderLayout; @@ -71,19 +72,19 @@ /// /// @author Shai Almog public class StickyHeaderContainer extends Container { - /// Replace the pinned header without a fade or shift. As the next - /// section's header rises into the pinned slot from below it slides - /// over the pinned header (which is hidden during the overlap) and - /// then takes its place once it reaches the top. + /// Replace the pinned header without a fade or shift. The pinned + /// header stays in place until the rising section reaches the slot + /// top, at which point the swap is instant. The rising header is + /// hidden behind the pinned slot during the overlap. public static final int TRANSITION_NONE = 0; /// As the next section's header rises into the pinned slot from below /// it pushes the pinned header up and out of the slot in sync with /// the scroll, replacing it once the rising header reaches the top. public static final int TRANSITION_SLIDE = 1; /// As the next section's header rises into the pinned slot from below - /// the pinned header fades to transparency, revealing the rising - /// header behind it. The swap happens once the rising header reaches - /// the top and the pinned header has fully faded. + /// the pinned header both slides up and fades to transparency, so + /// it dissolves out of the slot while the rising header takes its + /// place. The swap happens once the rising header reaches the top. public static final int TRANSITION_FADE = 2; private final ScrollContainer scroller; @@ -102,6 +103,14 @@ public class StickyHeaderContainer extends Container { private int pushOffset; private int stickyHostBaseY; + /// Reverse-activation hysteresis. Once a section is pinned, scroll + /// inertia tends to bounce a few pixels past the swap boundary; if + /// each bounce flipped the active section the pinned header would + /// visibly jitter as it teleports between scroller-tracked and + /// slot-fixed positions. Suppressing tiny reverse swaps inside this + /// window absorbs the bounce. + private static final int SWAP_HYSTERESIS_PIXELS = 4; + private static class Section { final Component header; final Container placeholder; @@ -119,7 +128,7 @@ private static class Section { public StickyHeaderContainer() { super(); scroller = new ScrollContainer(); - stickyHost = new Container(new BorderLayout()); + stickyHost = new StickyHostContainer(); setLayout(new StickyOverlayLayout()); super.addComponent(scroller); @@ -133,6 +142,37 @@ public void scrollChanged(int scrollX, int scrollY, int oldscrollX, int oldscrol }); } + /// Overlay host that always paints its parent's background under the + /// pinned header. This keeps a transparent header UIID from showing + /// scroller content through the slot during a transition. + private final class StickyHostContainer extends Container { + StickyHostContainer() { + super(new BorderLayout()); + } + + @Override + public void paintBackground(Graphics g) { + Container parent = StickyHeaderContainer.this; + Style ps = parent.getStyle(); + byte transparency = ps.getBgTransparency(); + if (transparency != 0 && g.isAlphaSupported()) { + int oldColor = g.getColor(); + int oldAlpha = g.getAlpha(); + g.setColor(ps.getBgColor()); + g.setAlpha(transparency & 0xff); + g.fillRect(getX(), getY(), getWidth(), getHeight()); + g.setColor(oldColor); + g.setAlpha(oldAlpha); + } else if (transparency != 0) { + int oldColor = g.getColor(); + g.setColor(ps.getBgColor()); + g.fillRect(getX(), getY(), getWidth(), getHeight()); + g.setColor(oldColor); + } + super.paintBackground(g); + } + } + private static final class ScrollContainer extends Container { ScrollContainer() { super(BoxLayout.y()); @@ -305,6 +345,23 @@ public void updateSticky() { } } + if (newActive < activeIndex && activeIndex >= 0) { + // Inertial bounce on iOS routinely overshoots the swap + // boundary by a few pixels. If we deactivate immediately the + // pinned header teleports back into the scroller and is + // re-activated on the next forward bounce, producing a + // visible jitter. Hold the current active section across + // tiny reverse excursions; a real backwards scroll past the + // hysteresis window still deactivates normally. + Section curr = sections.get(activeIndex); + if (curr.placeholder.getParent() == scroller) { //NOPMD CompareObjectsWithEquals + int distancePastBoundary = curr.placeholder.getY() - sy; + if (distancePastBoundary > 0 && distancePastBoundary <= SWAP_HYSTERESIS_PIXELS) { + newActive = activeIndex; + } + } + } + boolean activationChanged = (newActive != activeIndex); if (activationChanged) { applyActivation(newActive); @@ -438,15 +495,25 @@ private void applyPushVisuals() { break; } case TRANSITION_NONE: { - // Hide the pinned header so the next section, which is - // already rising into this slot through the scroller, is - // visible underneath. The swap restores visibility. + // Keep the pinned header in place at full opacity. The + // rising section's header is below the slot in the + // scroller and stays hidden behind the pinned host until + // the swap, which is instant -- that is the "no + // transition" semantic. Hiding the host here would + // expose scroller content (e.g. the previous section's + // last entry) where the slot used to be. stickyHost.setY(stickyHostBaseY); stickyHost.getAllStyles().setOpacity(255); - stickyHost.setVisible(false); + stickyHost.setVisible(true); break; } case TRANSITION_FADE: { + // Combined slide-and-fade so the rising header is + // visibly filling the slot from below while the pinned + // header dissolves on its way out. With a fade-only + // implementation the user sees the slot become empty as + // the pinned header alpha drops, since the rising + // header is still well below the slot top. int alpha = 255; if (activeH > 0) { alpha = 255 - (pushOffset * 255) / activeH; @@ -456,7 +523,7 @@ private void applyPushVisuals() { alpha = 255; } } - stickyHost.setY(stickyHostBaseY); + stickyHost.setY(stickyHostBaseY - pushOffset); stickyHost.getAllStyles().setOpacity(alpha); stickyHost.setVisible(true); break; diff --git a/maven/core-unittests/src/test/java/com/codename1/components/StickyHeaderContainerTest.java b/maven/core-unittests/src/test/java/com/codename1/components/StickyHeaderContainerTest.java index 6c7aa06e44..89babdf9be 100644 --- a/maven/core-unittests/src/test/java/com/codename1/components/StickyHeaderContainerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/components/StickyHeaderContainerTest.java @@ -203,21 +203,27 @@ void slidePushShiftsStickyHostUp() { } @FormTest - void noneStyleHidesStickyHostDuringOverlap() { + void noneStyleKeepsStickyHostInPlaceDuringOverlap() { StickyHeaderContainer sticky = build(3); sticky.setTransitionStyle(StickyHeaderContainer.TRANSITION_NONE); sticky.setScrollPosition(100); sticky.updateSticky(); + int baseY = sticky.getStickyHost().getY(); assertTrue(sticky.getStickyHost().isVisible(), "host stays visible when there is no overlap"); - // Inside the push window: NONE hides the host so the rising - // section header in the scroller is what the user sees. + // Inside the push window: NONE keeps the host pinned in place + // and fully opaque so the rising section in the scroller below + // does not bleed through where the slot used to be. sticky.setScrollPosition(SECTION_STRIDE - HEADER_HEIGHT + 20); sticky.updateSticky(); - assertFalse(sticky.getStickyHost().isVisible(), - "NONE must hide the host so the rising header covers the slot"); + assertTrue(sticky.getStickyHost().isVisible(), + "NONE must keep the host visible during the overlap"); + assertEquals(baseY, sticky.getStickyHost().getY(), + "NONE must not shift the host while overlapping"); + assertEquals(255, sticky.getStickyHost().getStyle().getOpacity(), + "NONE keeps the host fully opaque"); // Past the boundary: new section is pinned, host shows again. sticky.setScrollPosition(SECTION_STRIDE + 10); @@ -228,28 +234,65 @@ void noneStyleHidesStickyHostDuringOverlap() { } @FormTest - void fadeStyleReducesStickyHostOpacityWithPush() { + void fadeStyleSlidesAndFadesStickyHostWithPush() { StickyHeaderContainer sticky = build(3); sticky.setTransitionStyle(StickyHeaderContainer.TRANSITION_FADE); sticky.setScrollPosition(100); sticky.updateSticky(); + int baseY = sticky.getStickyHost().getY(); assertEquals(255, sticky.getStickyHost().getStyle().getOpacity(), "fully opaque outside the push window"); sticky.setScrollPosition(SECTION_STRIDE - HEADER_HEIGHT + 25); sticky.updateSticky(); - // pushOffset = 25 of 50 → alpha = 255 - 25*255/50 = 127 (or 128 by rounding) + // pushOffset = 25 of 50 → host slides up by 25 AND alpha = 127 + // (or 128 by rounding). The combined slide+fade gives the rising + // header room to rise into the slot from below while the pinned + // header dissolves out the top. int alpha = sticky.getStickyHost().getStyle().getOpacity(); assertTrue(alpha > 100 && alpha < 160, "fade alpha should be roughly half-way through, was " + alpha); + assertEquals(baseY - 25, sticky.getStickyHost().getY(), + "FADE must shift the host up by the push offset"); assertTrue(sticky.getStickyHost().isVisible()); - // After the swap: full opacity again on the new header. + // After the swap: full opacity again on the new header, + // host returns to the base Y. sticky.setScrollPosition(SECTION_STRIDE + 10); sticky.updateSticky(); assertEquals(1, sticky.getActiveSectionIndex()); assertEquals(255, sticky.getStickyHost().getStyle().getOpacity()); + assertEquals(baseY, sticky.getStickyHost().getY()); + } + + @FormTest + void shortReverseScrollDoesNotFlipActiveSection() { + // 4 sections so that scrollDimension > viewport height + the + // boundary positions used below; otherwise non-tensile scrollers + // clamp scrollY into a range that hides this hysteresis window. + StickyHeaderContainer sticky = build(4); + + sticky.setScrollPosition(SECTION_STRIDE + 5); + sticky.updateSticky(); + assertEquals(1, sticky.getActiveSectionIndex()); + + // Tiny reverse bounce of 1 pixel past the boundary — well + // inside the hysteresis window — must not deactivate section 1. + // Without hysteresis an inertial bounce here would teleport the + // pinned header back into the scroller and re-pin it on the + // next forward bounce, producing a visible jitter. + sticky.setScrollPosition(SECTION_STRIDE - 1); + sticky.updateSticky(); + assertEquals(1, sticky.getActiveSectionIndex(), + "small inertial bounces must not flip the active section"); + + // A larger reverse scroll past the hysteresis window does + // deactivate as before. + sticky.setScrollPosition(SECTION_STRIDE - 20); + sticky.updateSticky(); + assertEquals(0, sticky.getActiveSectionIndex(), + "scrolling well back past the boundary deactivates"); } @FormTest diff --git a/scripts/android/screenshots/StickyHeaderFadeTransitionScreenshotTest.png b/scripts/android/screenshots/StickyHeaderFadeTransitionScreenshotTest.png index 626dbf2d89..df672583c2 100644 Binary files a/scripts/android/screenshots/StickyHeaderFadeTransitionScreenshotTest.png and b/scripts/android/screenshots/StickyHeaderFadeTransitionScreenshotTest.png differ diff --git a/scripts/ios/screenshots/StickyHeaderFadeTransitionScreenshotTest.png b/scripts/ios/screenshots/StickyHeaderFadeTransitionScreenshotTest.png index 4b8cd94905..d90579ac98 100644 Binary files a/scripts/ios/screenshots/StickyHeaderFadeTransitionScreenshotTest.png and b/scripts/ios/screenshots/StickyHeaderFadeTransitionScreenshotTest.png differ