diff --git a/CodenameOne/src/com/codename1/components/StickyHeaderContainer.java b/CodenameOne/src/com/codename1/components/StickyHeaderContainer.java new file mode 100644 index 0000000000..754a9658ba --- /dev/null +++ b/CodenameOne/src/com/codename1/components/StickyHeaderContainer.java @@ -0,0 +1,548 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.components; + +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; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.Layout; +import com.codename1.ui.plaf.Style; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// 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. +/// +/// Sections are added with `addSection(header, content)`. The header is a +/// real component that participates in the scroll: when it is the active +/// section's header it is moved into a pinned overlay slot at the top of +/// the container, and a same-sized invisible placeholder is left behind in +/// the scroll content so nothing jumps. Because the pinned header is the +/// same instance, action listeners and child components remain interactive +/// while it is pinned. +/// +/// ```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()); +/// for (int i = 0; i < 5; i++) { +/// items.add(new Label(c + " entry " + i)); +/// } +/// sticky.addSection(header, items); +/// } +/// form.add(BorderLayout.CENTER, sticky); +/// ``` +/// +/// @author Shai Almog +public class StickyHeaderContainer extends Container { + /// Replace the pinned header without animation. + 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. + public static final int TRANSITION_SLIDE = 1; + /// Fade the outgoing header to transparency on top of the incoming one. + 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
(); + + private int activeIndex = -1; + + private int transitionStyle = TRANSITION_SLIDE; + private int transitionDurationMillis = DEFAULT_TRANSITION_DURATION_MS; + + private Image transitionOutgoing; + private long transitionStartMs; + private boolean transitionForward; + private int transitionSlotHeight; + private int stickyHostBaseY; + + private static class Section { + final Component header; + final Container placeholder; + int height; + + Section(Component header) { + this.header = header; + this.placeholder = new Container(); + this.placeholder.setVisible(false); + } + } + + /// Creates an empty sticky header container. Add sections via + /// `addSection(header, content)`. + public StickyHeaderContainer() { + super(); + scroller = new ScrollContainer(); + stickyHost = new Container(new BorderLayout()); + + setLayout(new StickyOverlayLayout()); + super.addComponent(scroller); + super.addComponent(stickyHost); + + scroller.addScrollListener(new ScrollListener() { + @Override + public void scrollChanged(int scrollX, int scrollY, int oldscrollX, int oldscrollY) { + updateSticky(); + } + }); + } + + private static final class ScrollContainer extends Container { + ScrollContainer() { + super(BoxLayout.y()); + setScrollableY(true); + } + + void setScrollYExposed(int y) { + setScrollY(y); + } + } + + /// Adds a section consisting of a sticky header and its content. The + /// content may be `null` for a header-only section. Returns this for + /// chaining. + public StickyHeaderContainer addSection(Component header, Component content) { + if (header == null) { + throw new IllegalArgumentException("header cannot be null"); + } + Section s = new Section(header); + sections.add(s); + scroller.addComponent(header); + if (content != null) { + scroller.addComponent(content); + } + return this; + } + + /// Adds a header-only section. + public StickyHeaderContainer addSection(Component header) { + return addSection(header, null); + } + + /// Returns the inner scrolling container that hosts the section content. + /// Use this to add non-section components such as a footer, or for + /// programmatic scrolling via [#setScrollPosition(int)]. + public Container getScrollContainer() { + return scroller; + } + + /// Returns the overlay container that hosts the currently-pinned header. + /// While a section is active its header lives here; otherwise the host + /// is empty and zero-height. + public Container getStickyHost() { + return stickyHost; + } + + /// Returns an unmodifiable view of the registered sticky headers in the + /// order they were added. + public List getStickyHeaders() { + List out = new ArrayList(sections.size()); + for (Section s : sections) { + out.add(s.header); + } + return Collections.unmodifiableList(out); + } + + /// Returns the index of the currently pinned section, or `-1` if no + /// header is currently pinned. + 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]. + public void setTransitionStyle(int style) { + if (style != TRANSITION_NONE && style != TRANSITION_SLIDE && style != TRANSITION_FADE) { + throw new IllegalArgumentException("Unknown transition style: " + style); + } + this.transitionStyle = style; + } + + 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()]. + public void setTransitionDurationMillis(int millis) { + if (millis < 0) { + throw new IllegalArgumentException("duration cannot be negative"); + } + this.transitionDurationMillis = millis; + } + + public int getTransitionDurationMillis() { + return transitionDurationMillis; + } + + /// Returns true while a header replacement animation is in progress. + public boolean isTransitionInProgress() { + if (transitionOutgoing == null) { + return false; + } + return AnimationTime.now() - transitionStartMs < transitionDurationMillis; + } + + /// Returns the progress of the in-flight transition as a fraction in + /// `[0, 1]`. Returns `1` when no transition is running. + public float getTransitionProgress() { + if (transitionOutgoing == null || transitionDurationMillis <= 0) { + return 1f; + } + long elapsed = AnimationTime.now() - transitionStartMs; + if (elapsed <= 0) { + return 0f; + } + if (elapsed >= transitionDurationMillis) { + return 1f; + } + return (float) elapsed / (float) transitionDurationMillis; + } + + /// Sets the scroll position of the inner scroll container. The value is + /// clamped to the valid range and triggers a sticky-header recompute. + public void setScrollPosition(int y) { + scroller.setScrollYExposed(y); + } + + /// Returns the current scroll position of the inner scroll container. + public int getScrollPosition() { + return scroller.getScrollY(); + } + + /// Removes all sections and content from the container. + public void clearSections() { + deactivate(); + scroller.removeAll(); + 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. + public void updateSticky() { + if (sections.isEmpty()) { + deactivate(); + return; + } + if (scroller.getHeight() <= 0) { + return; + } + int sy = scroller.getScrollY(); + int newActive = -1; + for (int i = 0; i < sections.size(); i++) { + Section s = sections.get(i); + Component anchor = s.placeholder.getParent() == scroller ? s.placeholder : s.header; //NOPMD CompareObjectsWithEquals + if (anchor.getParent() != scroller) { //NOPMD CompareObjectsWithEquals + continue; + } + int aH = anchor.getHeight(); + if (aH <= 0) { + aH = anchor.getPreferredH(); + } + if (aH <= 0) { + continue; + } + int relY = anchor.getY() - sy; + if (relY < 0) { + newActive = i; + } + } + + if (newActive == activeIndex) { + return; + } + + applyActivation(newActive); + } + + private int activeHeightFor(int index) { + Section s = sections.get(index); + if (s.height > 0) { + return s.height; + } + int h = s.header.getHeight(); + if (h <= 0) { + h = s.header.getPreferredH(); + } + 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(); + } + } + + boolean forward; + if (!wasActive) { + forward = true; + } else if (!willBeActive) { + forward = false; + } else { + forward = newActive > activeIndex; + } + + if (wasActive) { + Section prev = sections.get(activeIndex); + stickyHost.removeAll(); + int idx = scroller.getComponentIndex(prev.placeholder); + if (idx >= 0) { + scroller.removeComponent(prev.placeholder); + scroller.addComponent(idx, prev.header); + } + } + + if (willBeActive) { + Section next = sections.get(newActive); + int idx = scroller.getComponentIndex(next.header); + int h = next.header.getHeight(); + if (h <= 0) { + h = next.header.getPreferredH(); + } + int w = next.header.getWidth(); + if (w <= 0) { + w = scroller.getWidth(); + } + next.height = h; + next.placeholder.setPreferredSize(new Dimension(w, h)); + if (idx >= 0) { + scroller.removeComponent(next.header); + scroller.addComponent(idx, next.placeholder); + } + stickyHost.addComponent(BorderLayout.CENTER, next.header); + } + + activeIndex = newActive; + + if (outgoingSnapshot != null && willBeActive) { + transitionOutgoing = outgoingSnapshot; + transitionStartMs = AnimationTime.now(); + transitionForward = forward; + transitionSlotHeight = outgoingHeight > 0 ? outgoingHeight + : activeHeightFor(activeIndex); + registerForAnimation(); + } else { + stopTransition(); + } + + scroller.layoutContainer(); + stickyHost.layoutContainer(); + layoutContainer(); + if (isInitialized()) { + repaint(); + } + } + + private void deactivate() { + if (activeIndex < 0) { + 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); + } + } + + private void applyTransitionStateToIncoming() { + if (transitionOutgoing == null) { + stickyHost.setY(stickyHostBaseY); + stickyHost.getAllStyles().setOpacity(255); + 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); + } + } + + private final class StickyOverlayLayout extends Layout { + @Override + public void layoutContainer(Container parent) { + Style ps = parent.getStyle(); + int x = ps.getPaddingLeft(parent.isRTL()); + int y = ps.getPaddingTop(); + int innerW = parent.getLayoutWidth() - parent.getSideGap() - ps.getHorizontalPadding(); + int innerH = parent.getLayoutHeight() - parent.getBottomGap() - ps.getVerticalPadding(); + if (innerW < 0) { + innerW = 0; + } + if (innerH < 0) { + innerH = 0; + } + + scroller.setX(x); + scroller.setY(y); + scroller.setWidth(innerW); + scroller.setHeight(innerH); + + int headerH = activeIndex >= 0 ? activeHeightFor(activeIndex) : 0; + stickyHostBaseY = y; + stickyHost.setX(x); + stickyHost.setY(y); + stickyHost.setWidth(innerW); + stickyHost.setHeight(headerH); + } + + @Override + public Dimension getPreferredSize(Container parent) { + Dimension d = scroller.getPreferredSize(); + Style ps = parent.getStyle(); + return new Dimension(d.getWidth() + ps.getHorizontalPadding(), + d.getHeight() + ps.getVerticalPadding()); + } + + @Override + public boolean isOverlapSupported() { + return true; + } + } +} 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 new file mode 100644 index 0000000000..a5c550bc83 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/components/StickyHeaderContainerTest.java @@ -0,0 +1,240 @@ +package com.codename1.components; + +import com.codename1.junit.FormTest; +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.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class StickyHeaderContainerTest extends UITestBase { + + private static final int FRAME_WIDTH = 200; + private static final int FRAME_HEIGHT = 600; + private static final int HEADER_HEIGHT = 50; + 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(); + assertThrows(IllegalArgumentException.class, () -> sticky.addSection(null, new Container())); + } + + @FormTest + void sectionsAreRegisteredInOrder() { + StickyHeaderContainer sticky = build(3); + assertEquals(3, sticky.getStickyHeaders().size()); + assertEquals(6, sticky.getScrollContainer().getComponentCount(), + "scroller should hold one entry per header and content"); + } + + @FormTest + void initialStateIsInactive() { + StickyHeaderContainer sticky = build(3); + assertEquals(-1, sticky.getActiveSectionIndex()); + assertEquals(0, sticky.getStickyHost().getComponentCount()); + } + + @FormTest + void firstSectionPinsAfterScroll() { + StickyHeaderContainer sticky = build(3); + Component first = sticky.getStickyHeaders().get(0); + + sticky.setScrollPosition(10); + sticky.updateSticky(); + + assertEquals(0, sticky.getActiveSectionIndex()); + assertEquals(1, sticky.getStickyHost().getComponentCount()); + assertSame(first, sticky.getStickyHost().getComponentAt(0), + "the same header instance must be moved into the sticky host"); + } + + @FormTest + void secondSectionTakesOverPastBoundary() { + StickyHeaderContainer sticky = build(3); + Component second = sticky.getStickyHeaders().get(1); + + sticky.setScrollPosition(SECTION_STRIDE + 10); + sticky.updateSticky(); + + assertEquals(1, sticky.getActiveSectionIndex()); + assertSame(second, sticky.getStickyHost().getComponentAt(0)); + } + + @FormTest + void scrollingBackToTopDeactivates() { + StickyHeaderContainer sticky = build(3); + + sticky.setScrollPosition(50); + sticky.updateSticky(); + assertEquals(0, sticky.getActiveSectionIndex()); + + sticky.setScrollPosition(0); + sticky.updateSticky(); + + assertEquals(-1, sticky.getActiveSectionIndex()); + assertEquals(0, sticky.getStickyHost().getComponentCount()); + } + + @FormTest + void clearSectionsResetsState() { + StickyHeaderContainer sticky = build(3); + sticky.setScrollPosition(50); + sticky.updateSticky(); + assertEquals(0, sticky.getActiveSectionIndex()); + + sticky.clearSections(); + + assertEquals(0, sticky.getStickyHeaders().size()); + assertEquals(0, sticky.getScrollContainer().getComponentCount()); + assertEquals(-1, sticky.getActiveSectionIndex()); + assertEquals(0, sticky.getStickyHost().getComponentCount()); + } + + @FormTest + void headerOnlySectionRegisters() { + StickyHeaderContainer sticky = new StickyHeaderContainer(); + Container header = sized("solo", FRAME_WIDTH, HEADER_HEIGHT); + sticky.addSection(header); + sticky.setWidth(FRAME_WIDTH); + sticky.setHeight(FRAME_HEIGHT); + sticky.layoutContainer(); + sticky.getScrollContainer().layoutContainer(); + + assertEquals(1, sticky.getStickyHeaders().size()); + assertEquals(1, sticky.getScrollContainer().getComponentCount(), + "header-only section adds a single child to the scroller"); + } + + @FormTest + void transitionStyleDefaultsToSlide() { + StickyHeaderContainer sticky = new StickyHeaderContainer(); + assertEquals(StickyHeaderContainer.TRANSITION_SLIDE, sticky.getTransitionStyle()); + } + + @FormTest + void transitionStyleSetterAcceptsKnownValues() { + StickyHeaderContainer sticky = new StickyHeaderContainer(); + sticky.setTransitionStyle(StickyHeaderContainer.TRANSITION_FADE); + assertEquals(StickyHeaderContainer.TRANSITION_FADE, sticky.getTransitionStyle()); + sticky.setTransitionStyle(StickyHeaderContainer.TRANSITION_NONE); + assertEquals(StickyHeaderContainer.TRANSITION_NONE, sticky.getTransitionStyle()); + sticky.setTransitionStyle(StickyHeaderContainer.TRANSITION_SLIDE); + assertEquals(StickyHeaderContainer.TRANSITION_SLIDE, sticky.getTransitionStyle()); + } + + @FormTest + void transitionStyleRejectsUnknownValue() { + StickyHeaderContainer sticky = new StickyHeaderContainer(); + assertThrows(IllegalArgumentException.class, () -> sticky.setTransitionStyle(42)); + } + + @FormTest + void transitionDurationRejectsNegative() { + StickyHeaderContainer sticky = new StickyHeaderContainer(); + assertThrows(IllegalArgumentException.class, () -> sticky.setTransitionDurationMillis(-1)); + } + + @FormTest + void slideTransitionStartsOnActiveChange() { + StickyHeaderContainer sticky = build(3); + sticky.setTransitionDurationMillis(200); + + AnimationTime.setTime(1000L); + sticky.setScrollPosition(10); + sticky.updateSticky(); + // First activation has no outgoing snapshot so no transition runs. + assertTrue(sticky.getTransitionProgress() == 1f); + + 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(0f, sticky.getTransitionProgress(), 0.001f); + + AnimationTime.setTime(1100L); + assertEquals(0.5f, sticky.getTransitionProgress(), 0.05f); + + AnimationTime.setTime(1300L); + assertTrue(sticky.getTransitionProgress() >= 1f); + } + + @FormTest + void noneStyleSkipsTransitionAnimation() { + StickyHeaderContainer sticky = build(3); + sticky.setTransitionStyle(StickyHeaderContainer.TRANSITION_NONE); + + sticky.setScrollPosition(10); + sticky.updateSticky(); + sticky.setScrollPosition(SECTION_STRIDE + 10); + sticky.updateSticky(); + + assertEquals(1, sticky.getActiveSectionIndex()); + assertTrue(sticky.getTransitionProgress() == 1f, + "TRANSITION_NONE must not start an animation"); + } + + @FormTest + void scrollingPastSeveralBoundariesSettlesOnLast() { + StickyHeaderContainer sticky = build(4); + sticky.setTransitionDurationMillis(0); + + sticky.setScrollPosition(SECTION_STRIDE * 3 + 10); + sticky.updateSticky(); + + assertEquals(3, sticky.getActiveSectionIndex()); + assertSame(sticky.getStickyHeaders().get(3), + sticky.getStickyHost().getComponentAt(0)); + } + + private static StickyHeaderContainer build(int sectionCount) { + StickyHeaderContainer sticky = new StickyHeaderContainer(); + zero(sticky); + zero(sticky.getScrollContainer()); + zero(sticky.getStickyHost()); + for (int i = 0; i < sectionCount; i++) { + Container header = sized("H" + i, FRAME_WIDTH, HEADER_HEIGHT); + Container content = sized("C" + i, FRAME_WIDTH, CONTENT_HEIGHT); + sticky.addSection(header, content); + } + sticky.setWidth(FRAME_WIDTH); + sticky.setHeight(FRAME_HEIGHT); + sticky.layoutContainer(); + sticky.getScrollContainer().layoutContainer(); + return sticky; + } + + private static Container sized(final String name, final int w, final int h) { + Container c = new Container() { + @Override + protected Dimension calcPreferredSize() { + return new Dimension(w, h); + } + }; + c.setName(name); + Style s = c.getAllStyles(); + s.setPadding(0, 0, 0, 0); + s.setMargin(0, 0, 0, 0); + return c; + } + + private static void zero(Container c) { + Style s = c.getAllStyles(); + s.setPadding(0, 0, 0, 0); + s.setMargin(0, 0, 0, 0); + } +} diff --git a/scripts/android/screenshots/StickyHeaderFadeTransitionScreenshotTest.png b/scripts/android/screenshots/StickyHeaderFadeTransitionScreenshotTest.png new file mode 100644 index 0000000000..7675838749 Binary files /dev/null and b/scripts/android/screenshots/StickyHeaderFadeTransitionScreenshotTest.png differ diff --git a/scripts/android/screenshots/StickyHeaderScreenshotTest.png b/scripts/android/screenshots/StickyHeaderScreenshotTest.png new file mode 100644 index 0000000000..bd7896ef83 Binary files /dev/null and b/scripts/android/screenshots/StickyHeaderScreenshotTest.png differ diff --git a/scripts/android/screenshots/StickyHeaderSlideTransitionScreenshotTest.png b/scripts/android/screenshots/StickyHeaderSlideTransitionScreenshotTest.png new file mode 100644 index 0000000000..04c70eec22 Binary files /dev/null and b/scripts/android/screenshots/StickyHeaderSlideTransitionScreenshotTest.png differ diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/AbstractStickyHeaderScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/AbstractStickyHeaderScreenshotTest.java new file mode 100644 index 0000000000..a2b7f87258 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/AbstractStickyHeaderScreenshotTest.java @@ -0,0 +1,123 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.components.StickyHeaderContainer; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.plaf.Style; + +/// Shared scaffolding for [StickyHeaderContainer] screenshot tests. Subclasses +/// build a host form with a fixed list of contrasting sections and choose +/// either a fast scroll sweep (driving the container's scroll position +/// across all boundaries) or a slow scroll demo (holding scroll past one +/// boundary and stepping `AnimationTime` through the swap animation). +abstract class AbstractStickyHeaderScreenshotTest extends AbstractAnimationScreenshotTest { + protected static final int SECTION_COUNT = 5; + protected static final int ITEMS_PER_SECTION = 4; + protected static final int[] HEADER_COLORS = { + 0x118ab2, 0xef476f, 0x06d6a0, 0xffd166, 0x8338ec + }; + + protected Form host; + protected StickyHeaderContainer sticky; + + @Override + public boolean runTest() throws Exception { + if ("HTML5".equals(Display.getInstance().getPlatformName())) { + // The JS port truncates the 6-frame composite stream when chunked + // through console logging, so the reassembled PNG is missing + // bytes and the screenshot decoder rejects it. Skip on HTML5; + // iOS, Android and JavaSE still cover the visual contract. + System.out.println("CN1SS:INFO:test=" + getImageName() + " status=SKIPPED reason=js-port-chunk-truncation"); + done(); + return true; + } + return super.runTest(); + } + + @Override + protected void prepareCapture(int frameWidth, int frameHeight) { + super.prepareCapture(frameWidth, frameHeight); + host = new Form(getDisplayTitle()); + host.setLayout(new BorderLayout()); + host.setWidth(frameWidth); + host.setHeight(frameHeight); + host.setVisible(true); + + sticky = new StickyHeaderContainer(); + configureTransition(sticky); + Style ss = sticky.getAllStyles(); + ss.setBgColor(0xf3f4f6); + ss.setBgTransparency(255); + ss.setPadding(0, 0, 0, 0); + ss.setMargin(0, 0, 0, 0); + + Style scrollerStyle = sticky.getScrollContainer().getAllStyles(); + scrollerStyle.setBgColor(0xf3f4f6); + scrollerStyle.setBgTransparency(255); + scrollerStyle.setPadding(0, 0, 0, 0); + scrollerStyle.setMargin(0, 0, 0, 0); + + for (int s = 0; s < SECTION_COUNT; s++) { + Label header = makeHeader("Section " + (char) ('A' + s), HEADER_COLORS[s]); + Container content = new Container(BoxLayout.y()); + Style cs = content.getAllStyles(); + cs.setBgColor(0xffffff); + cs.setBgTransparency(255); + cs.setPadding(0, 0, 0, 0); + cs.setMargin(0, 0, 0, 0); + for (int i = 0; i < ITEMS_PER_SECTION; i++) { + Label item = new Label(((char) ('A' + s)) + " - row " + (i + 1)); + Style is = item.getAllStyles(); + is.setBgColor(0xffffff); + is.setBgTransparency(255); + is.setFgColor(0x111827); + is.setPadding(10, 10, 14, 14); + is.setMargin(0, 0, 0, 0); + content.add(item); + } + sticky.addSection(header, content); + } + + host.add(BorderLayout.CENTER, sticky); + host.layoutContainer(); + sticky.layoutContainer(); + sticky.getScrollContainer().layoutContainer(); + } + + /// Subclasses configure the transition style and duration here before + /// sections are added. + protected abstract void configureTransition(StickyHeaderContainer sticky); + + @Override + protected void finishCapture() { + host = null; + sticky = null; + super.finishCapture(); + } + + private static Label makeHeader(String text, int color) { + Label l = new Label(text); + Style s = l.getAllStyles(); + s.setBgColor(color); + s.setBgTransparency(255); + s.setFgColor(0xffffff); + s.setPadding(12, 12, 18, 18); + s.setMargin(0, 0, 0, 0); + return l; + } + + protected int sectionStrideHeight() { + if (sticky == null) { + return 0; + } + Container scroller = sticky.getScrollContainer(); + if (scroller.getComponentCount() < 2) { + return 0; + } + return scroller.getComponentAt(2).getY() - scroller.getComponentAt(0).getY(); + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index f475145e5c..39e86683d2 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -36,17 +36,27 @@ public final class Cn1ssDeviceRunner extends DeviceRunner { - // Previously 30_000. In the JavaScript port each test's onShowCompleted -> UITimer - // -> emitCurrentFormScreenshot -> done() chain typically completes in ~1500ms - // (see BaseTest.registerReadyCallback). Tests that never reach done() shouldn't - // block the whole suite for 30s each — the overall CI browser lifetime is only - // ~150s, so even three stuck tests used to consume the entire budget and prevent - // later tests from ever running. 10s is still 6× the normal budget which is plenty - // of margin for the rare genuinely slow form; iOS/Android are unaffected (they use - // their own deadline logic in their respective runners). - private static final int TEST_TIMEOUT_MS = 10000; + // Per-test deadline cap. On the JavaScript port the overall CI browser + // lifetime is only ~150s, so a stuck test mustn't eat the whole budget; + // the tight 10s cap kept the suite from being blocked by the rare hung + // form (e.g. KotlinUiTest's missing cn1lib). iOS / Android / JavaSE + // run with much longer wall budgets so a per-test cap of 30s is safe - + // it only matters for genuinely stuck tests, and 30s leaves headroom + // for theme captures whose chunked-log emission is rate-limited (Android + // throttles 500-byte chunks at 30ms each to keep logcat from dropping + // lines, so a 60KB PNG plus its preview takes ~6s per appearance, and + // a dual-appearance test like SpanLabelTheme legitimately needs ~12s + // even on a healthy device). + private static final int TEST_TIMEOUT_MS_HTML5 = 10000; + private static final int TEST_TIMEOUT_MS_NATIVE = 30000; private static final int TEST_POLL_INTERVAL_MS = 50; + private static int testTimeoutMs() { + return "HTML5".equals(Display.getInstance().getPlatformName()) + ? TEST_TIMEOUT_MS_HTML5 + : TEST_TIMEOUT_MS_NATIVE; + } + // Calling Display.getInstance() at static-init time was tripping the iOS // class loader (Cn1ssDeviceRunner failed to load before runSuite could // log a single starting test=...). Keep the array as a plain literal - @@ -70,6 +80,9 @@ public final class Cn1ssDeviceRunner extends DeviceRunner { new AnimateHierarchyScreenshotTest(), new AnimateUnlayoutScreenshotTest(), new SmoothScrollScreenshotTest(), + new StickyHeaderScreenshotTest(), + new StickyHeaderSlideTransitionScreenshotTest(), + new StickyHeaderFadeTransitionScreenshotTest(), new TensileBounceScreenshotTest(), new ComponentReplaceFadeScreenshotTest(), new ComponentReplaceSlideScreenshotTest(), @@ -182,7 +195,7 @@ private void runNextTest(int index) { logThrowable("runTest:" + testName, t); testClass.fail(String.valueOf(t)); } - awaitTestCompletion(index, testClass, testName, System.currentTimeMillis() + TEST_TIMEOUT_MS); + awaitTestCompletion(index, testClass, testName, System.currentTimeMillis() + testTimeoutMs()); }); } @@ -255,6 +268,9 @@ private static boolean isJsSkippedAnimationTest(String testName) { || "AnimateHierarchyScreenshotTest".equals(testName) || "AnimateUnlayoutScreenshotTest".equals(testName) || "SmoothScrollScreenshotTest".equals(testName) + || "StickyHeaderScreenshotTest".equals(testName) + || "StickyHeaderSlideTransitionScreenshotTest".equals(testName) + || "StickyHeaderFadeTransitionScreenshotTest".equals(testName) || "TensileBounceScreenshotTest".equals(testName) || "ComponentReplaceFadeScreenshotTest".equals(testName) || "ComponentReplaceSlideScreenshotTest".equals(testName) @@ -279,6 +295,9 @@ private static boolean isJsSkippedScreenshotTest(String testName) { || "ToastBarTopPositionScreenshotTest".equals(testName) || "ValidatorLightweightPickerScreenshotTest".equals(testName) || "LightweightPickerButtonsScreenshotTest".equals(testName) + || "StickyHeaderScreenshotTest".equals(testName) + || "StickyHeaderSlideTransitionScreenshotTest".equals(testName) + || "StickyHeaderFadeTransitionScreenshotTest".equals(testName) // graphics tests || "AffineScale".equals(testName) || "Clip".equals(testName) diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunnerHelper.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunnerHelper.java index 645a5c7c2d..4f5c94db43 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunnerHelper.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunnerHelper.java @@ -20,9 +20,14 @@ interface Cn1ssDeviceRunnerHelper { // tests added each emitting ~150KB PNGs (~400 chunks each), the JDK 21 // Android job started flaking with one random "PNG chunk truncated before // CRC" per run on different tests across runs (SlideHorizontalTransitionTest - // on one CI run, MultiButtonTheme_dark on the next). Bumping to 30ms gives - // logcat extra drain time without doubling overall emission cost. - int DELAY_ANDROID = 30; + // on one CI run, MultiButtonTheme_dark on the next). Bumping to 30ms gave + // logcat extra drain time. With three more screenshot tests added by + // the sticky-headers PR (#4829) the JDK 21 entry started flaking again + // (one random theme stream truncated per run). Bumping to 50ms; the + // Cn1ssDeviceRunner per-test deadline is now 30s on native platforms so + // the slower emission still completes inside the budget for a dual + // appearance test (~14s for two captures). + int DELAY_ANDROID = 50; int MAX_PREVIEW_BYTES = 20 * 1024; String PREVIEW_CHANNEL = "PREVIEW"; int[] PREVIEW_QUALITIES = new int[] {60, 50, 40, 35, 30, 25, 20, 18, 16, 14, 12, 10, 8, 6, 5, 4, 3, 2, 1}; 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 new file mode 100644 index 0000000000..82414b54d2 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StickyHeaderFadeTransitionScreenshotTest.java @@ -0,0 +1,45 @@ +package com.codenameone.examples.hellocodenameone.tests; + +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. +public class StickyHeaderFadeTransitionScreenshotTest extends AbstractStickyHeaderScreenshotTest { + private boolean transitionTriggered; + private int targetScrollY; + + @Override + protected int getAnimationDurationMillis() { + return 600; + } + + @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.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); + sticky.updateSticky(); + transitionTriggered = true; + } + host.paintComponent(g, true); + } +} 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 new file mode 100644 index 0000000000..aad1ea661c --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StickyHeaderScreenshotTest.java @@ -0,0 +1,51 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.components.StickyHeaderContainer; +import com.codename1.ui.Graphics; +import com.codename1.ui.animations.Motion; + +/// 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. +public class StickyHeaderScreenshotTest extends AbstractStickyHeaderScreenshotTest { + private Motion scrollMotion; + + @Override + protected int getAnimationDurationMillis() { + return 900; + } + + @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 + protected void prepareCapture(int frameWidth, int frameHeight) { + super.prepareCapture(frameWidth, frameHeight); + int contentHeight = sticky.getScrollContainer().getScrollDimension().getHeight(); + int viewportHeight = sticky.getScrollContainer().getHeight(); + int maxScroll = Math.max(0, contentHeight - viewportHeight); + scrollMotion = Motion.createEaseInOutMotion(0, maxScroll, getAnimationDurationMillis()); + scrollMotion.start(); + } + + @Override + protected void renderFrame(Graphics g, int width, int height, double progress, int frameIndex) { + sticky.setScrollPosition(scrollMotion.getValue()); + host.paintComponent(g, true); + } + + @Override + protected void finishCapture() { + scrollMotion = null; + super.finishCapture(); + } +} 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 new file mode 100644 index 0000000000..b5d8f18853 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/StickyHeaderSlideTransitionScreenshotTest.java @@ -0,0 +1,49 @@ +package com.codenameone.examples.hellocodenameone.tests; + +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. +public class StickyHeaderSlideTransitionScreenshotTest extends AbstractStickyHeaderScreenshotTest { + private boolean transitionTriggered; + private int targetScrollY; + + @Override + protected int getAnimationDurationMillis() { + return 600; + } + + @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); + 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); + sticky.updateSticky(); + transitionTriggered = true; + } + host.paintComponent(g, true); + } +} diff --git a/scripts/ios/screenshots/StickyHeaderFadeTransitionScreenshotTest.png b/scripts/ios/screenshots/StickyHeaderFadeTransitionScreenshotTest.png new file mode 100644 index 0000000000..475f8f0049 Binary files /dev/null and b/scripts/ios/screenshots/StickyHeaderFadeTransitionScreenshotTest.png differ diff --git a/scripts/ios/screenshots/StickyHeaderScreenshotTest.png b/scripts/ios/screenshots/StickyHeaderScreenshotTest.png new file mode 100644 index 0000000000..0ae95e51a8 Binary files /dev/null and b/scripts/ios/screenshots/StickyHeaderScreenshotTest.png differ diff --git a/scripts/ios/screenshots/StickyHeaderSlideTransitionScreenshotTest.png b/scripts/ios/screenshots/StickyHeaderSlideTransitionScreenshotTest.png new file mode 100644 index 0000000000..32a6e04ec2 Binary files /dev/null and b/scripts/ios/screenshots/StickyHeaderSlideTransitionScreenshotTest.png differ