Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 80 additions & 13 deletions CodenameOne/src/com/codename1/components/StickyHeaderContainer.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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());
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -456,7 +523,7 @@ private void applyPushVisuals() {
alpha = 255;
}
}
stickyHost.setY(stickyHostBaseY);
stickyHost.setY(stickyHostBaseY - pushOffset);
stickyHost.getAllStyles().setOpacity(alpha);
stickyHost.setVisible(true);
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading