diff --git a/.github/workflows/scripts-android.yml b/.github/workflows/scripts-android.yml index 2bbcf41f30..95f198b8ff 100644 --- a/.github/workflows/scripts-android.yml +++ b/.github/workflows/scripts-android.yml @@ -189,6 +189,21 @@ jobs: if-no-files-found: warn retention-days: 14 compression-level: 6 + # Logcat captures from connectedAndroidTest and the in-script retry + # (`connectedAndroidTest.log`, `connectedAndroidTest-retry.log`). These + # are the only place to debug CN1SS chunk-decode flakes — they hold + # the raw base64 chunks plus the `am start` / pidof diagnostics. If + # this upload step disappears, the next decode flake will be + # un-debuggable: keep it. + - name: Upload Android instrumentation logs + if: always() && matrix.id == 'default' + uses: actions/upload-artifact@v4 + with: + name: android-instrumentation-logs + path: artifacts/connectedAndroidTest*.log + if-no-files-found: warn + retention-days: 14 + compression-level: 6 - name: Upload Android test report if: always() && matrix.id == 'default' uses: actions/upload-artifact@v4 diff --git a/CodenameOne/src/com/codename1/components/StickyHeaderContainer.java b/CodenameOne/src/com/codename1/components/StickyHeaderContainer.java index 754a9658ba..0814b7c3c7 100644 --- a/CodenameOne/src/com/codename1/components/StickyHeaderContainer.java +++ b/CodenameOne/src/com/codename1/components/StickyHeaderContainer.java @@ -24,10 +24,6 @@ import com.codename1.ui.Component; import com.codename1.ui.Container; -import com.codename1.ui.Form; -import com.codename1.ui.Graphics; -import com.codename1.ui.Image; -import com.codename1.ui.animations.AnimationTime; import com.codename1.ui.events.ScrollListener; import com.codename1.ui.geom.Dimension; import com.codename1.ui.layouts.BorderLayout; @@ -41,12 +37,15 @@ /// A scrollable container that pins the most recently scrolled-past section /// header to the top of its viewport, in the style of the iOS contacts list -/// or sectioned material lists. As the user scrolls past a section boundary -/// the previous header is replaced by the next one through a configurable -/// staged transition: a directional slide (slides up on forward scroll, down -/// on reverse scroll) or a cross-fade. Transitions are time-driven so a slow -/// scroll surfaces every animation frame at full duration while a fast -/// scroll lets the latest swap supersede earlier in-flight ones. +/// or sectioned material lists. As the next section's header rises into the +/// pinned slot the previous header is replaced through a configurable +/// scroll-driven transition: a directional slide where the rising header +/// pushes the pinned one up and out, an instant cover where the rising +/// header simply slides over the pinned one, or a fade where the pinned +/// header fades to transparency as the next section closes the gap. +/// Transitions are driven by scroll position so the visual stays in sync +/// with the user's gesture and there is no time-based animation that lags +/// behind a slow drag or skips ahead on a fling. /// /// Sections are added with `addSection(header, content)`. The header is a /// real component that participates in the scroll: when it is the active @@ -59,7 +58,6 @@ /// ```java /// StickyHeaderContainer sticky = new StickyHeaderContainer(); /// sticky.setTransitionStyle(StickyHeaderContainer.TRANSITION_SLIDE); -/// sticky.setTransitionDurationMillis(250); /// for (char c = 'A'; c <= 'Z'; c++) { /// Label header = new Label("" + c, "StickyHeader"); /// Container items = new Container(BoxLayout.y()); @@ -73,17 +71,21 @@ /// /// @author Shai Almog public class StickyHeaderContainer extends Container { - /// Replace the pinned header without animation. + /// 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. public static final int TRANSITION_NONE = 0; - /// Slide the outgoing header out of the slot while the incoming header - /// slides in. Forward scroll slides upward, reverse scroll slides - /// downward. + /// 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; - /// Fade the outgoing header to transparency on top of the incoming one. + /// 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. public static final int TRANSITION_FADE = 2; - private static final int DEFAULT_TRANSITION_DURATION_MS = 250; - private final ScrollContainer scroller; private final Container stickyHost; private final List
sections = new ArrayList
(); @@ -91,12 +93,13 @@ public class StickyHeaderContainer extends Container { private int activeIndex = -1; private int transitionStyle = TRANSITION_SLIDE; - private int transitionDurationMillis = DEFAULT_TRANSITION_DURATION_MS; + private int transitionDurationMillis; - private Image transitionOutgoing; - private long transitionStartMs; - private boolean transitionForward; - private int transitionSlotHeight; + /// Pixels of overlap between the pinned header's slot and the next + /// section's header. `0` means the next section is still fully below + /// the slot; equal to the pinned header's height means the swap is + /// imminent. + private int pushOffset; private int stickyHostBaseY; private static class Section { @@ -192,23 +195,30 @@ public int getActiveSectionIndex() { return activeIndex; } - /// Selects how the pinned header is replaced when the active section - /// changes. One of [#TRANSITION_NONE], [#TRANSITION_SLIDE] (default) or - /// [#TRANSITION_FADE]. + /// Selects how the pinned header is replaced when the next section + /// rises into the slot. One of [#TRANSITION_NONE], [#TRANSITION_SLIDE] + /// (default) or [#TRANSITION_FADE]. public void setTransitionStyle(int style) { if (style != TRANSITION_NONE && style != TRANSITION_SLIDE && style != TRANSITION_FADE) { throw new IllegalArgumentException("Unknown transition style: " + style); } + if (this.transitionStyle == style) { + return; + } this.transitionStyle = style; + applyPushVisuals(); + if (isInitialized()) { + repaint(); + } } public int getTransitionStyle() { return transitionStyle; } - /// Sets the duration of the header replacement animation in - /// milliseconds. A value of `0` makes transitions instantaneous - /// regardless of [#getTransitionStyle()]. + /// Retained for API compatibility. Transitions are now scroll-driven so + /// the per-frame duration no longer affects visuals; the value is + /// validated and stored but otherwise unused. public void setTransitionDurationMillis(int millis) { if (millis < 0) { throw new IllegalArgumentException("duration cannot be negative"); @@ -220,28 +230,28 @@ public int getTransitionDurationMillis() { return transitionDurationMillis; } - /// Returns true while a header replacement animation is in progress. + /// Returns true while the next section's header is overlapping the + /// pinned slot, i.e. the scroll-driven transition is mid-flight. public boolean isTransitionInProgress() { - if (transitionOutgoing == null) { - return false; - } - return AnimationTime.now() - transitionStartMs < transitionDurationMillis; + return pushOffset > 0; } /// Returns the progress of the in-flight transition as a fraction in - /// `[0, 1]`. Returns `1` when no transition is running. + /// `[0, 1]`: `0` when the next section is just touching the slot from + /// below and `1` when it has fully displaced the pinned header. + /// Returns `0` when no transition is in progress. public float getTransitionProgress() { - if (transitionOutgoing == null || transitionDurationMillis <= 0) { - return 1f; + if (activeIndex < 0 || pushOffset <= 0) { + return 0f; } - long elapsed = AnimationTime.now() - transitionStartMs; - if (elapsed <= 0) { + int activeH = activeHeightFor(activeIndex); + if (activeH <= 0) { return 0f; } - if (elapsed >= transitionDurationMillis) { + if (pushOffset >= activeH) { return 1f; } - return (float) elapsed / (float) transitionDurationMillis; + return (float) pushOffset / (float) activeH; } /// Sets the scroll position of the inner scroll container. The value is @@ -262,10 +272,10 @@ public void clearSections() { sections.clear(); } - /// Recomputes which section header should be pinned and updates the - /// overlay accordingly. The container calls this internally on scroll; - /// call it explicitly when section content has been mutated outside of - /// a normal layout cycle. + /// Recomputes which section header should be pinned and how far the + /// next section has displaced it. Called internally on every scroll + /// event; call it explicitly when section content has been mutated + /// outside of a normal layout cycle. public void updateSticky() { if (sections.isEmpty()) { deactivate(); @@ -295,11 +305,22 @@ public void updateSticky() { } } - if (newActive == activeIndex) { - return; + boolean activationChanged = (newActive != activeIndex); + if (activationChanged) { + applyActivation(newActive); + sy = scroller.getScrollY(); } - applyActivation(newActive); + int newPush = computePushOffset(sy); + boolean pushChanged = (newPush != pushOffset); + pushOffset = newPush; + + if (activationChanged || pushChanged) { + applyPushVisuals(); + if (isInitialized()) { + repaint(); + } + } } private int activeHeightFor(int index) { @@ -314,32 +335,42 @@ private int activeHeightFor(int index) { return h; } - private void applyActivation(int newActive) { - Image outgoingSnapshot = null; - int outgoingHeight = 0; - boolean wasActive = activeIndex >= 0; - boolean willBeActive = newActive >= 0; - - if (wasActive && willBeActive - && transitionStyle != TRANSITION_NONE - && transitionDurationMillis > 0) { - Component oldHeader = sections.get(activeIndex).header; - outgoingHeight = oldHeader.getHeight(); - if (oldHeader.getWidth() > 0 && outgoingHeight > 0) { - outgoingSnapshot = oldHeader.toImage(); - } + private int computePushOffset(int sy) { + if (activeIndex < 0) { + return 0; } - - boolean forward; - if (!wasActive) { - forward = true; - } else if (!willBeActive) { - forward = false; - } else { - forward = newActive > activeIndex; + int activeH = activeHeightFor(activeIndex); + if (activeH <= 0) { + return 0; } + int nextIdx = activeIndex + 1; + if (nextIdx >= sections.size()) { + return 0; + } + Section next = sections.get(nextIdx); + Component anchor = next.placeholder.getParent() == scroller ? next.placeholder : next.header; //NOPMD CompareObjectsWithEquals + if (anchor.getParent() != scroller) { //NOPMD CompareObjectsWithEquals + return 0; + } + int aH = anchor.getHeight(); + if (aH <= 0) { + aH = anchor.getPreferredH(); + } + if (aH <= 0) { + return 0; + } + int relY = anchor.getY() - sy; + if (relY >= activeH) { + return 0; + } + if (relY <= 0) { + return activeH; + } + return activeH - relY; + } - if (wasActive) { + private void applyActivation(int newActive) { + if (activeIndex >= 0) { Section prev = sections.get(activeIndex); stickyHost.removeAll(); int idx = scroller.getComponentIndex(prev.placeholder); @@ -349,7 +380,7 @@ private void applyActivation(int newActive) { } } - if (willBeActive) { + if (newActive >= 0) { Section next = sections.get(newActive); int idx = scroller.getComponentIndex(next.header); int h = next.header.getHeight(); @@ -370,24 +401,14 @@ private void applyActivation(int newActive) { } activeIndex = newActive; - - if (outgoingSnapshot != null && willBeActive) { - transitionOutgoing = outgoingSnapshot; - transitionStartMs = AnimationTime.now(); - transitionForward = forward; - transitionSlotHeight = outgoingHeight > 0 ? outgoingHeight - : activeHeightFor(activeIndex); - registerForAnimation(); - } else { - stopTransition(); - } + // The newly active section starts fresh: no overlap with the + // section after it until further scrolling brings it into the + // push window. + pushOffset = 0; scroller.layoutContainer(); stickyHost.layoutContainer(); layoutContainer(); - if (isInitialized()) { - repaint(); - } } private void deactivate() { @@ -395,112 +416,53 @@ private void deactivate() { return; } applyActivation(-1); - } - - private void registerForAnimation() { - Form f = getComponentForm(); - if (f != null) { - f.registerAnimated(this); - } - } - - private void stopTransition() { - transitionOutgoing = null; - stickyHost.setY(stickyHostBaseY); - stickyHost.getAllStyles().setOpacity(255); - Form f = getComponentForm(); - if (f != null) { - f.deregisterAnimated(this); + applyPushVisuals(); + if (isInitialized()) { + repaint(); } } - private void applyTransitionStateToIncoming() { - if (transitionOutgoing == null) { + private void applyPushVisuals() { + if (activeIndex < 0 || pushOffset <= 0) { stickyHost.setY(stickyHostBaseY); stickyHost.getAllStyles().setOpacity(255); + stickyHost.setVisible(true); return; } - long elapsed = AnimationTime.now() - transitionStartMs; - if (elapsed >= transitionDurationMillis) { - stickyHost.setY(stickyHostBaseY); - stickyHost.getAllStyles().setOpacity(255); - return; - } - if (elapsed < 0) { - elapsed = 0; - } - float progress = (float) elapsed / (float) transitionDurationMillis; - - if (transitionStyle == TRANSITION_SLIDE && transitionSlotHeight > 0) { - int direction = transitionForward ? 1 : -1; - int yOffset = (int) (direction * (1f - progress) * transitionSlotHeight); - stickyHost.setY(stickyHostBaseY + yOffset); - stickyHost.getAllStyles().setOpacity(255); - } else if (transitionStyle == TRANSITION_FADE) { - int alpha = (int) (255 * progress); - stickyHost.getAllStyles().setOpacity(alpha); - stickyHost.setY(stickyHostBaseY); - } - } - - @Override - public boolean animate() { - if (transitionOutgoing == null) { - return false; - } - long elapsed = AnimationTime.now() - transitionStartMs; - if (elapsed >= transitionDurationMillis) { - stopTransition(); - return true; - } - applyTransitionStateToIncoming(); - return true; - } - - @Override - public void paint(Graphics g) { - applyTransitionStateToIncoming(); - super.paint(g); - } - - @Override - protected void paintGlass(Graphics g) { - super.paintGlass(g); - if (transitionOutgoing == null) { - return; - } - long elapsed = AnimationTime.now() - transitionStartMs; - if (elapsed < 0) { - elapsed = 0; - } - if (elapsed >= transitionDurationMillis) { - return; - } - float progress = (float) elapsed / (float) transitionDurationMillis; - - Style ps = getStyle(); - int slotAbsX = getAbsoluteX() + ps.getPaddingLeft(isRTL()); - int slotAbsY = getAbsoluteY() + ps.getPaddingTop(); - - if (transitionStyle == TRANSITION_FADE) { - int alpha = (int) (255 * (1f - progress)); - int saved = g.getAlpha(); - g.setAlpha(alpha); - g.drawImage(transitionOutgoing, slotAbsX, slotAbsY); - g.setAlpha(saved); - } else if (transitionStyle == TRANSITION_SLIDE && transitionSlotHeight > 0) { - int direction = transitionForward ? -1 : 1; - int yOffset = (int) (direction * progress * transitionSlotHeight); - g.drawImage(transitionOutgoing, slotAbsX, slotAbsY + yOffset); - } - } - - @Override - protected void deinitialize() { - super.deinitialize(); - Form f = getComponentForm(); - if (f != null) { - f.deregisterAnimated(this); + int activeH = activeHeightFor(activeIndex); + switch (transitionStyle) { + case TRANSITION_SLIDE: { + stickyHost.setY(stickyHostBaseY - pushOffset); + stickyHost.getAllStyles().setOpacity(255); + stickyHost.setVisible(true); + 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. + stickyHost.setY(stickyHostBaseY); + stickyHost.getAllStyles().setOpacity(255); + stickyHost.setVisible(false); + break; + } + case TRANSITION_FADE: { + int alpha = 255; + if (activeH > 0) { + alpha = 255 - (pushOffset * 255) / activeH; + if (alpha < 0) { + alpha = 0; + } else if (alpha > 255) { + alpha = 255; + } + } + stickyHost.setY(stickyHostBaseY); + stickyHost.getAllStyles().setOpacity(alpha); + stickyHost.setVisible(true); + break; + } + default: + break; } } @@ -527,9 +489,11 @@ public void layoutContainer(Container parent) { int headerH = activeIndex >= 0 ? activeHeightFor(activeIndex) : 0; stickyHostBaseY = y; stickyHost.setX(x); - stickyHost.setY(y); stickyHost.setWidth(innerW); stickyHost.setHeight(headerH); + // Re-apply any in-flight push so the host's Y matches the + // current push offset relative to the freshly computed base. + applyPushVisuals(); } @Override 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 a5c550bc83..6c7aa06e44 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 @@ -4,12 +4,11 @@ import com.codename1.junit.UITestBase; import com.codename1.ui.Component; import com.codename1.ui.Container; -import com.codename1.ui.animations.AnimationTime; import com.codename1.ui.geom.Dimension; import com.codename1.ui.plaf.Style; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -22,11 +21,6 @@ class StickyHeaderContainerTest extends UITestBase { private static final int CONTENT_HEIGHT = 200; private static final int SECTION_STRIDE = HEADER_HEIGHT + CONTENT_HEIGHT; - @org.junit.jupiter.api.AfterEach - void resetAnimationTime() { - AnimationTime.reset(); - } - @FormTest void addSectionRejectsNullHeader() { StickyHeaderContainer sticky = new StickyHeaderContainer(); @@ -149,49 +143,118 @@ void transitionDurationRejectsNegative() { } @FormTest - void slideTransitionStartsOnActiveChange() { + void pushProgressTracksScrollPositionWithinWindow() { StickyHeaderContainer sticky = build(3); - sticky.setTransitionDurationMillis(200); - AnimationTime.setTime(1000L); - sticky.setScrollPosition(10); + // Pin section 0 well clear of the next boundary: no overlap yet. + sticky.setScrollPosition(100); + sticky.updateSticky(); + assertEquals(0, sticky.getActiveSectionIndex()); + assertFalse(sticky.isTransitionInProgress(), + "no overlap when next header is far below the slot"); + assertEquals(0f, sticky.getTransitionProgress(), 0.001f); + + // Section 1's header sits at Y = SECTION_STRIDE (250). It enters + // the push window when scrollY > SECTION_STRIDE - HEADER_HEIGHT + // (i.e. the header's top is within the slot height). + sticky.setScrollPosition(SECTION_STRIDE - HEADER_HEIGHT + 10); + sticky.updateSticky(); + assertEquals(0, sticky.getActiveSectionIndex(), + "the swap shouldn't happen until next.relY <= 0"); + assertTrue(sticky.isTransitionInProgress(), + "expected push to be in flight inside the window"); + assertEquals(10f / HEADER_HEIGHT, sticky.getTransitionProgress(), 0.001f); + + sticky.setScrollPosition(SECTION_STRIDE - HEADER_HEIGHT + 25); sticky.updateSticky(); - // First activation has no outgoing snapshot so no transition runs. - assertTrue(sticky.getTransitionProgress() == 1f); + assertEquals(25f / HEADER_HEIGHT, sticky.getTransitionProgress(), 0.001f); + sticky.setScrollPosition(SECTION_STRIDE - HEADER_HEIGHT + 40); + sticky.updateSticky(); + assertEquals(40f / HEADER_HEIGHT, sticky.getTransitionProgress(), 0.001f); + + // Cross the boundary and section 1 takes over; no new overlap with + // section 2 yet, so the push offset resets to 0. sticky.setScrollPosition(SECTION_STRIDE + 10); sticky.updateSticky(); - // Second activation starts a transition because there is an outgoing - // header to swap. - assertTrue(sticky.isTransitionInProgress(), "expected transition after section swap"); + assertEquals(1, sticky.getActiveSectionIndex()); + assertFalse(sticky.isTransitionInProgress()); assertEquals(0f, sticky.getTransitionProgress(), 0.001f); + } - AnimationTime.setTime(1100L); - assertEquals(0.5f, sticky.getTransitionProgress(), 0.05f); + @FormTest + void slidePushShiftsStickyHostUp() { + StickyHeaderContainer sticky = build(3); + sticky.setTransitionStyle(StickyHeaderContainer.TRANSITION_SLIDE); + + sticky.setScrollPosition(100); + sticky.updateSticky(); + int baseY = sticky.getStickyHost().getY(); - AnimationTime.setTime(1300L); - assertTrue(sticky.getTransitionProgress() >= 1f); + sticky.setScrollPosition(SECTION_STRIDE - HEADER_HEIGHT + 30); + sticky.updateSticky(); + // pushOffset = 30 → host shifts up by 30. + assertEquals(baseY - 30, sticky.getStickyHost().getY(), + "slide style must shift the host up by the push offset"); + assertEquals(255, sticky.getStickyHost().getStyle().getOpacity(), + "slide style keeps the host fully opaque"); + assertTrue(sticky.getStickyHost().isVisible(), + "slide style keeps the host visible during the push"); } @FormTest - void noneStyleSkipsTransitionAnimation() { + void noneStyleHidesStickyHostDuringOverlap() { StickyHeaderContainer sticky = build(3); sticky.setTransitionStyle(StickyHeaderContainer.TRANSITION_NONE); - sticky.setScrollPosition(10); + sticky.setScrollPosition(100); sticky.updateSticky(); + 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. + 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"); + + // Past the boundary: new section is pinned, host shows again. sticky.setScrollPosition(SECTION_STRIDE + 10); sticky.updateSticky(); + assertEquals(1, sticky.getActiveSectionIndex()); + assertTrue(sticky.getStickyHost().isVisible(), + "after the swap the host is visible with the new header"); + } + @FormTest + void fadeStyleReducesStickyHostOpacityWithPush() { + StickyHeaderContainer sticky = build(3); + sticky.setTransitionStyle(StickyHeaderContainer.TRANSITION_FADE); + + sticky.setScrollPosition(100); + sticky.updateSticky(); + 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) + int alpha = sticky.getStickyHost().getStyle().getOpacity(); + assertTrue(alpha > 100 && alpha < 160, + "fade alpha should be roughly half-way through, was " + alpha); + assertTrue(sticky.getStickyHost().isVisible()); + + // After the swap: full opacity again on the new header. + sticky.setScrollPosition(SECTION_STRIDE + 10); + sticky.updateSticky(); assertEquals(1, sticky.getActiveSectionIndex()); - assertTrue(sticky.getTransitionProgress() == 1f, - "TRANSITION_NONE must not start an animation"); + assertEquals(255, sticky.getStickyHost().getStyle().getOpacity()); } @FormTest void scrollingPastSeveralBoundariesSettlesOnLast() { StickyHeaderContainer sticky = build(4); - sticky.setTransitionDurationMillis(0); sticky.setScrollPosition(SECTION_STRIDE * 3 + 10); sticky.updateSticky(); diff --git a/scripts/android/screenshots/StickyHeaderFadeTransitionScreenshotTest.png b/scripts/android/screenshots/StickyHeaderFadeTransitionScreenshotTest.png index 7675838749..626dbf2d89 100644 Binary files a/scripts/android/screenshots/StickyHeaderFadeTransitionScreenshotTest.png and b/scripts/android/screenshots/StickyHeaderFadeTransitionScreenshotTest.png differ diff --git a/scripts/android/screenshots/StickyHeaderScreenshotTest.png b/scripts/android/screenshots/StickyHeaderScreenshotTest.png index bd7896ef83..54d43d1cba 100644 Binary files a/scripts/android/screenshots/StickyHeaderScreenshotTest.png and b/scripts/android/screenshots/StickyHeaderScreenshotTest.png differ diff --git a/scripts/android/screenshots/StickyHeaderSlideTransitionScreenshotTest.png b/scripts/android/screenshots/StickyHeaderSlideTransitionScreenshotTest.png index 04c70eec22..617541d6d1 100644 Binary files a/scripts/android/screenshots/StickyHeaderSlideTransitionScreenshotTest.png and b/scripts/android/screenshots/StickyHeaderSlideTransitionScreenshotTest.png differ diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StickyHeaderFadeTransitionScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StickyHeaderFadeTransitionScreenshotTest.java index 82414b54d2..82337f851a 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StickyHeaderFadeTransitionScreenshotTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StickyHeaderFadeTransitionScreenshotTest.java @@ -3,14 +3,12 @@ import com.codename1.components.StickyHeaderContainer; import com.codename1.ui.Graphics; -/// Slow-scroll demo for the fade transition style: holds the scroll past one -/// boundary and steps `AnimationTime` through the swap so each frame samples -/// the cross-fade at progress 0%, 20%, …, 100%. Variants of -/// [StickyHeaderSlideTransitionScreenshotTest] capturing -/// [StickyHeaderContainer#TRANSITION_FADE] instead. +/// Slow-scroll demo for the fade transition style: holds section A pinned +/// and steps the scroll position through the push window so each frame +/// samples the pinned header's opacity dropping from fully opaque to fully +/// transparent as the next section closes in. The next header rises into +/// the slot through the scroller and is revealed as the pinned one fades. public class StickyHeaderFadeTransitionScreenshotTest extends AbstractStickyHeaderScreenshotTest { - private boolean transitionTriggered; - private int targetScrollY; @Override protected int getAnimationDurationMillis() { @@ -20,26 +18,34 @@ protected int getAnimationDurationMillis() { @Override protected void configureTransition(StickyHeaderContainer sticky) { sticky.setTransitionStyle(StickyHeaderContainer.TRANSITION_FADE); - sticky.setTransitionDurationMillis(getAnimationDurationMillis()); } @Override protected void prepareCapture(int frameWidth, int frameHeight) { super.prepareCapture(frameWidth, frameHeight); - int stride = sectionStrideHeight(); - sticky.setScrollPosition(stride - 1); + sticky.setScrollPosition(1); sticky.updateSticky(); - targetScrollY = stride * 2 - 1; - transitionTriggered = false; } @Override protected void renderFrame(Graphics g, int width, int height, double progress, int frameIndex) { - if (frameIndex == 0 && !transitionTriggered) { - sticky.setScrollPosition(targetScrollY); + int sectionStride = sectionStrideHeight(); + int headerH = pinnedHeaderHeight(); + if (sectionStride > 0 && headerH > 0) { + int startScroll = sectionStride - headerH; + int span = headerH - 1; + int scrollY = startScroll + (int) Math.round(progress * span); + sticky.setScrollPosition(scrollY); sticky.updateSticky(); - transitionTriggered = true; } host.paintComponent(g, true); } + + private int pinnedHeaderHeight() { + int h = sticky.getStickyHost().getHeight(); + if (h <= 0 && !sticky.getStickyHeaders().isEmpty()) { + h = sticky.getStickyHeaders().get(0).getPreferredH(); + } + return h; + } } diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StickyHeaderScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StickyHeaderScreenshotTest.java index aad1ea661c..eb22513b92 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StickyHeaderScreenshotTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StickyHeaderScreenshotTest.java @@ -6,9 +6,9 @@ /// Fast-scroll demo: drives the container's scroll position across the full /// content range over the capture duration so each frame samples a different -/// scroll offset. With a long transition duration relative to the per-frame -/// scroll delta the slide animations are still in flight when each frame is -/// captured, surfacing the staged header replacement. +/// scroll offset. With scroll-driven transitions, frames whose scroll lands +/// inside a section's push window will surface the partial overlap between +/// the outgoing pinned header and the incoming one. public class StickyHeaderScreenshotTest extends AbstractStickyHeaderScreenshotTest { private Motion scrollMotion; @@ -20,11 +20,6 @@ protected int getAnimationDurationMillis() { @Override protected void configureTransition(StickyHeaderContainer sticky) { sticky.setTransitionStyle(StickyHeaderContainer.TRANSITION_SLIDE); - // Pick a duration shorter than the per-frame scroll delta - // (900ms / 5 ≈ 180ms) so most frames capture the post-transition - // settled state, with the occasional in-flight overlap when scroll - // crosses a section boundary mid-frame. - sticky.setTransitionDurationMillis(150); } @Override diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StickyHeaderSlideTransitionScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StickyHeaderSlideTransitionScreenshotTest.java index b5d8f18853..99454324d5 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StickyHeaderSlideTransitionScreenshotTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StickyHeaderSlideTransitionScreenshotTest.java @@ -3,14 +3,11 @@ import com.codename1.components.StickyHeaderContainer; import com.codename1.ui.Graphics; -/// Slow-scroll demo: holds the scroll position just past one section -/// boundary and steps `AnimationTime` through a single forward slide -/// transition so each frame samples the swap at progress 0%, 20%, …, 100%. -/// Shows the outgoing header sliding upward out of the slot while the -/// incoming header rises into place. +/// Slow-scroll demo: holds section A pinned and steps the scroll position +/// through the push window so each frame samples a different overlap +/// fraction. Shows the next section's header rising into the slot and +/// pushing the pinned header up and out in sync with scroll. public class StickyHeaderSlideTransitionScreenshotTest extends AbstractStickyHeaderScreenshotTest { - private boolean transitionTriggered; - private int targetScrollY; @Override protected int getAnimationDurationMillis() { @@ -20,30 +17,39 @@ protected int getAnimationDurationMillis() { @Override protected void configureTransition(StickyHeaderContainer sticky) { sticky.setTransitionStyle(StickyHeaderContainer.TRANSITION_SLIDE); - // Match the capture window so progress maps 1:1 onto the 6 frames. - sticky.setTransitionDurationMillis(getAnimationDurationMillis()); } @Override protected void prepareCapture(int frameWidth, int frameHeight) { super.prepareCapture(frameWidth, frameHeight); - // Pre-pin the first section so the captured swap is the second - // boundary - that way the demo shows a header-to-header replacement - // rather than a header materialising from nothing. - int stride = sectionStrideHeight(); - sticky.setScrollPosition(stride - 1); + // Pin the first section so the captured push is a header-to-header + // replacement rather than one materialising from nothing. + sticky.setScrollPosition(1); sticky.updateSticky(); - targetScrollY = stride * 2 - 1; - transitionTriggered = false; } @Override protected void renderFrame(Graphics g, int width, int height, double progress, int frameIndex) { - if (frameIndex == 0 && !transitionTriggered) { - sticky.setScrollPosition(targetScrollY); + int sectionStride = sectionStrideHeight(); + int headerH = pinnedHeaderHeight(); + if (sectionStride > 0 && headerH > 0) { + // Walk scroll from the moment the next header touches the slot + // (relY == headerH, push == 0) to one pixel before the swap + // (relY == 1, push == headerH - 1). + int startScroll = sectionStride - headerH; + int span = headerH - 1; + int scrollY = startScroll + (int) Math.round(progress * span); + sticky.setScrollPosition(scrollY); sticky.updateSticky(); - transitionTriggered = true; } host.paintComponent(g, true); } + + private int pinnedHeaderHeight() { + int h = sticky.getStickyHost().getHeight(); + if (h <= 0 && !sticky.getStickyHeaders().isEmpty()) { + h = sticky.getStickyHeaders().get(0).getPreferredH(); + } + return h; + } } diff --git a/scripts/ios/screenshots/StickyHeaderFadeTransitionScreenshotTest.png b/scripts/ios/screenshots/StickyHeaderFadeTransitionScreenshotTest.png index 475f8f0049..4b8cd94905 100644 Binary files a/scripts/ios/screenshots/StickyHeaderFadeTransitionScreenshotTest.png and b/scripts/ios/screenshots/StickyHeaderFadeTransitionScreenshotTest.png differ diff --git a/scripts/ios/screenshots/StickyHeaderScreenshotTest.png b/scripts/ios/screenshots/StickyHeaderScreenshotTest.png index 0ae95e51a8..bf8f160a8d 100644 Binary files a/scripts/ios/screenshots/StickyHeaderScreenshotTest.png and b/scripts/ios/screenshots/StickyHeaderScreenshotTest.png differ diff --git a/scripts/ios/screenshots/StickyHeaderSlideTransitionScreenshotTest.png b/scripts/ios/screenshots/StickyHeaderSlideTransitionScreenshotTest.png index 32a6e04ec2..d2c568c9ee 100644 Binary files a/scripts/ios/screenshots/StickyHeaderSlideTransitionScreenshotTest.png and b/scripts/ios/screenshots/StickyHeaderSlideTransitionScreenshotTest.png differ diff --git a/scripts/run-android-instrumentation-tests.sh b/scripts/run-android-instrumentation-tests.sh index 1b3c02ec78..6823aeb19f 100755 --- a/scripts/run-android-instrumentation-tests.sh +++ b/scripts/run-android-instrumentation-tests.sh @@ -289,21 +289,45 @@ done # start` (no Gradle, no recompile, no reinstall) and re-decode just the # tests that previously failed. The on-device suite re-emits every test; # we only redo the work for the ones that need recovery. +# +# Why launching the main activity is enough to rerun the suite: +# HelloCodenameOne.runApp() (the Lifecycle entry point packaged in the +# main APK) spawns a worker thread that calls Cn1ssDeviceRunner.runSuite(), +# which iterates every BaseTest and emits CN1SS:* markers via System.out. +# The instrumentation runner is just a thin wrapper around the same +# launch — we can drive it directly from adb. +# +# RIGHT FIX, KEEP IT THAT WAY: +# * Re-install the main APK before relaunching. The Gradle +# `connectedAndroidTest` task uninstalls BOTH the .test APK and the +# app-under-test APK at teardown (look for two "DeviceConnector ... +# uninstalling " lines in the gradle log to confirm). So by the +# time this retry block runs, the package is gone — `am start` then +# fails with "Activity not started, unable to resolve Intent ... pkg= +# " no matter how cleanly we launch it. We sidestep that by +# running `pm list packages` to check installation state and `adb +# install -r $MAIN_APK` to put the APK back when missing. We don't +# need the .test APK for the retry — the main APK contains +# Cn1ssDeviceRunner and re-runs the entire suite when launched. +# * Use `am start -W -a MAIN -c LAUNCHER -p $PACKAGE_NAME` to launch by +# intent filter rather than resolving the launcher activity component +# name. Older (`cmd package resolve-activity --brief`) returned just the +# `pkg/.Activity` component; newer Android prepends a "priority=…" line +# and may add leading whitespace, which broke our pattern match and +# silently fell back to `monkey`. Filter-based launch sidesteps the +# parsing entirely. +# * `-W` (wait + summary) returns a `Status=ok` line we can inspect. +# * Capture and log the output. DO NOT redirect to /dev/null and DO NOT +# use `|| true` to silence non-zero exits — the previous version did +# both, so when relaunch failed we got 600s of silence and zero signal +# to debug from. If this code path goes quiet on you in the future, +# check that nobody re-added an output redirect. +# * Verify the app process is actually running before committing to a +# 10-minute wait. If it isn't, fail fast with a clear log line so the +# CI surfaces the problem in seconds, not minutes. if [ "${#FAILED_TESTS[@]}" -gt 0 ] && [ -n "${PACKAGE_NAME:-}" ]; then ra_log "STAGE:RETRY -> Attempting decode recovery for: ${FAILED_TESTS[*]}" - # Resolve the package's launcher activity component so `am start` can - # invoke it directly. Falls back to the package name's main activity - # convention if cmd-package fails. - LAUNCH_COMPONENT="" - if RESOLVE_OUT="$("$ADB_BIN" shell cmd package resolve-activity --brief "$PACKAGE_NAME" 2>/dev/null | tr -d '\r' | tail -n1)"; then - LAUNCH_COMPONENT="${RESOLVE_OUT//[$'\t']/}" - fi - case "$LAUNCH_COMPONENT" in - "$PACKAGE_NAME"/*) ;; - *) LAUNCH_COMPONENT="" ;; - esac - # Stop the original logcat tail so the new capture starts cleanly. The # cleanup trap watches LOGCAT_PID, so reassign it to the retry tail # below and the trap will still kill the right process on exit. @@ -323,18 +347,90 @@ if [ "${#FAILED_TESTS[@]}" -gt 0 ] && [ -n "${PACKAGE_NAME:-}" ]; then LOGCAT_PID="$RETRY_LOGCAT_PID" sleep 2 - if [ -n "$LAUNCH_COMPONENT" ]; then - ra_log "Relaunching $LAUNCH_COMPONENT via adb am start" - "$ADB_BIN" shell am start -n "$LAUNCH_COMPONENT" >/dev/null 2>&1 || true + # Re-install the main APK if Gradle has already uninstalled it (it + # uninstalls both APKs at teardown — see the comment block above for the + # diagnostic that surfaced this). Match the package name with `grep -x` + # rather than relying on `pm list packages`'s substring filter so we + # don't get a false positive from `.test` or any other prefixed + # package that happens to be present. + if "$ADB_BIN" shell pm list packages "$PACKAGE_NAME" 2>/dev/null \ + | tr -d '\r' | grep -qx "package:$PACKAGE_NAME"; then + ra_log "$PACKAGE_NAME already installed; skipping reinstall" + else + MAIN_APK="$GRADLE_PROJECT_DIR/app/build/outputs/apk/debug/app-debug.apk" + if [ -f "$MAIN_APK" ]; then + ra_log "$PACKAGE_NAME not installed; reinstalling from $MAIN_APK" + if INSTALL_OUT="$("$ADB_BIN" install -r "$MAIN_APK" 2>&1 | tr -d '\r')"; then + INSTALL_RC=0 + else + INSTALL_RC=$? + fi + ra_log "adb install exit=${INSTALL_RC}, output:" + printf '%s\n' "$INSTALL_OUT" | sed 's/^/[run-android-instrumentation-tests] /' + if [ "$INSTALL_RC" -ne 0 ]; then + ra_log "WARN: adb install reported failure; the am start below will likely fail too" + fi + else + ra_log "ERROR: cannot reinstall — APK not found at $MAIN_APK" + fi + fi + + # Launch by intent filter (action=MAIN, category=LAUNCHER, filtered to + # our package). This avoids the launcher-component-resolution dance and + # works across Android versions. `-W` blocks until the activity is up + # and prints a Status= line we can parse. + # `set -euo pipefail` is on for this script, so capture the exit status + # via if/then/else; a bare `RC=$?` after the assignment would never run + # (the script would exit at the failing pipeline). Logging both the exit + # code and the raw output is the whole point of this block — see the + # "RIGHT FIX" comment above. + ra_log "Relaunching $PACKAGE_NAME via 'am start -W -a MAIN -c LAUNCHER -p $PACKAGE_NAME'" + if AM_START_OUT="$("$ADB_BIN" shell am start -W \ + -a android.intent.action.MAIN \ + -c android.intent.category.LAUNCHER \ + -p "$PACKAGE_NAME" 2>&1 | tr -d '\r')"; then + AM_START_RC=0 + else + AM_START_RC=$? + fi + ra_log "am start exit=${AM_START_RC}, output:" + printf '%s\n' "$AM_START_OUT" | sed 's/^/[run-android-instrumentation-tests] /' + + AM_STATUS="$(printf '%s\n' "$AM_START_OUT" \ + | awk -F= '/^[[:space:]]*Status[[:space:]]*=/ {print $2; exit}' \ + | tr -d '[:space:]')" + if [ "${AM_STATUS:-}" != "ok" ]; then + ra_log "WARN: am start did not report Status=ok (got '${AM_STATUS:-}'); the app may not be running" + fi + + # Verify the process is actually up before committing to a 10-minute + # wait. `pidof` exits non-zero with empty output when nothing matches. + # Try a few times because activity launch can race the first poll on + # slower emulators. + RETRY_PID="" + for _attempt in 1 2 3 4 5; do + # `pidof` exits non-zero when no match. Under pipefail+errexit that + # would kill the script, so explicitly tolerate the empty result. + RETRY_PID="$("$ADB_BIN" shell pidof "$PACKAGE_NAME" 2>/dev/null | tr -d '[:space:]\r')" || RETRY_PID="" + if [ -n "$RETRY_PID" ]; then break; fi + sleep 1 + done + if [ -z "$RETRY_PID" ]; then + # No process => app didn't launch. Skip the long wait so the CI + # surfaces the failure quickly; the connectedAndroidTest-retry.log + # artifact will contain whatever logcat captured. + ra_log "ERROR: $PACKAGE_NAME process not detected after relaunch; skipping retry wait" + RETRY_TIMEOUT=0 else - ra_log "Relaunching package $PACKAGE_NAME via monkey (launcher component unresolved)" - "$ADB_BIN" shell monkey -p "$PACKAGE_NAME" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true + ra_log "Retry app PID: $RETRY_PID" + RETRY_TIMEOUT=600 fi - RETRY_TIMEOUT=600 RETRY_START="$(date +%s)" - ra_log "Waiting up to ${RETRY_TIMEOUT}s for retry — will short-circuit as soon as every previously-failed test re-emits its CN1SS:END marker" - while true; do + if [ "$RETRY_TIMEOUT" -gt 0 ]; then + ra_log "Waiting up to ${RETRY_TIMEOUT}s for retry — will short-circuit as soon as every previously-failed test re-emits its CN1SS:END marker" + fi + while [ "$RETRY_TIMEOUT" -gt 0 ]; do have_all_ends=1 for failed_test in "${FAILED_TESTS[@]}"; do if ! grep -q "CN1SS:END:${failed_test}" "$RETRY_LOG" 2>/dev/null; then