diff --git a/CodenameOne/src/com/codename1/ui/Container.java b/CodenameOne/src/com/codename1/ui/Container.java index 8df363a4f09..5340f3c038b 100644 --- a/CodenameOne/src/com/codename1/ui/Container.java +++ b/CodenameOne/src/com/codename1/ui/Container.java @@ -142,6 +142,30 @@ public class Container extends Component implements Iterable { private int doLayoutDepth; private TmpInsets calcTmpInsets; private int calcPreferredSizeDepth; + /// The safe-area padding, in pixels (top, bottom, left, right), that the + /// last measurement of this container's preferred size added, or null when + /// there is nothing to hold its layout to. See checkMeasuredSafeArea. + private int[] measuredSafeInsets; + /// The safe-area padding, in pixels, the last layout of this container + /// applied; null before the first. + private int[] laidOutSafeInsets; + // What laidOutSafeInsets was computed against (see safeAreaContext). + private int[] laidOutSafeContext; + // The padding before the snap, while a layout holds the snapped one. + private int[] snappedFromPadding; + // Written by every safeAreaPadding call: the safe margins and surface the + // answer depended on. EDT only, like layoutNesting. + private static final int[] safeAreaContext = new int[6]; + /// Corrections checkMeasuredSafeArea made in a row; capped so a layout that + /// cannot settle is left as it is rather than laid out without end. + private int safeAreaCorrections; + private int safeAreaCleanPasses; + /// Nesting of doLayout across all containers; the outermost call re-runs a + /// layout that a safe-area container found was sized for the wrong inset. + /// Static because layout only runs on the EDT; a flag raised in another + /// tree laid out inside this one costs a wasted pass, nothing worse. + private static int layoutNesting; + private static boolean safeAreaRelayout; /// Constructs a new Container with a new layout manager and UIID /// @@ -1477,6 +1501,8 @@ HoverTracker getHoverTracker() { @Override void deinitializeImpl() { super.deinitializeImpl(); + // Wherever it is shown next, its inset there is unknown. + laidOutSafeInsets = null; int componentCount = components.size(); for (int iter = 0; iter < componentCount; iter++) { Component cmp = components.get(iter); @@ -2829,13 +2855,82 @@ private boolean isSafeAreaInternal(boolean checkParents) { /// For iPhone X primarily. This will check if the current bounds goes outside the /// safe area. If so, it will add padding to make the contents fit the safe area. - private boolean snapToSafeAreaInternal() { - if (isHidden()) { + private boolean snapToSafeAreaInternal(int[] pad, boolean measuring) { + Style style = getStyle(); + if (pad == null) { return false; } + // The padding set here lives for one measurement: the caller hands it + // straight back to TmpInsets.restore, which suppresses its events too. + // Announcing it made Component.styleChanged queue a revalidate of the + // whole Form from inside its own layout, so every pass queued the next + // and a form with a safe-area container never stopped laying out. + boolean suppressEvents = style.isSuppressChangeEvents(); + style.setSuppressChangeEvents(true); + if (pad[0] != style.getPaddingTop()) { + style.setPaddingUnitTop(Style.UNIT_TYPE_PIXELS); + style.setPaddingTop(pad[0]); + } + if (pad[1] != style.getPaddingBottom()) { + style.setPaddingUnitBottom(Style.UNIT_TYPE_PIXELS); + style.setPaddingBottom(pad[1]); + } + if (pad[2] != style.getPaddingLeftNoRTL()) { + style.setPaddingUnitLeft(Style.UNIT_TYPE_PIXELS); + style.setPaddingLeft(pad[2]); + } + if (pad[3] != style.getPaddingRightNoRTL()) { + style.setPaddingUnitRight(Style.UNIT_TYPE_PIXELS); + style.setPaddingRight(pad[3]); + } + style.setSuppressChangeEvents(suppressEvents); + if (measuring) { + boolean ownFlag = shouldCalcPreferredSize; + // The size being measured already counts this padding, so only the + // nested containers measure again. Flagging this one would discard + // the size the moment it is cached, and checkMeasuredSafeArea, + // which skips a container still to be measured, would never run. + int componentCount = components.size(); + for (int iter = 0; iter < componentCount; iter++) { + Component cmp = components.get(iter); + if (cmp instanceof Container) { + cmp.setShouldCalcPreferredSize(true); + } + } + // A child's flag reaches this container through setShouldLayout, + // and flagged here it would throw away the size being measured. + shouldCalcPreferredSize = ownFlag; + } else if (!shouldCalcPreferredSize) { + // What the suppressed PADDING event did, minus its revalidateLater + // of the parent: this and every nested container measure and lay + // out again against the snapped padding. Without it a nested + // container kept a layout made for the unsnapped one. + setShouldCalcPreferredSize(true); + } + return true; + } + + /// The padding (top, bottom, left, right, in pixels) that keeps this + /// container's content clear of the display's unsafe edges, at its current + /// bounds, or null when it needs none. + /// Pure: the style is read, never written. + private static boolean sameSafeAreaContext(int[] other) { + for (int i = 0; i < safeAreaContext.length; i++) { + if (safeAreaContext[i] != other[i]) { + return false; + } + } + return true; + } + + private int[] safeAreaPadding() { + safeAreaContext[0] = -1; + if (isHidden()) { + return null; + } Container safeAreaRoot = getSafeAreaRoot(); if (safeAreaRoot == null) { - return false; + return null; } Rectangle rect; int surfaceWidth; @@ -2862,13 +2957,19 @@ private boolean snapToSafeAreaInternal() { int safeRightMargin = surfaceWidth - rect.getWidth() - rect.getX(); int safeTopMargin = rect.getY(); int safeBottomMargin = surfaceHeight - rect.getHeight() - rect.getY(); + safeAreaContext[0] = safeLeftMargin; + safeAreaContext[1] = safeRightMargin; + safeAreaContext[2] = safeTopMargin; + safeAreaContext[3] = safeBottomMargin; + safeAreaContext[4] = safeAreaRoot.getWidth(); + safeAreaContext[5] = safeAreaRoot.getHeight(); if (safeLeftMargin == 0 && safeRightMargin == 0 && safeBottomMargin == 0 && safeTopMargin == 0) { - return false; + return null; } rect.setWidth(Math.max(0, safeAreaRoot.getWidth() - safeLeftMargin - safeRightMargin)); rect.setHeight(Math.max(0, safeAreaRoot.getHeight() - safeTopMargin - safeBottomMargin)); if (rect.getWidth() == 0 || rect.getHeight() == 0) { - return false; + return null; } Rectangle safeArea = rect; //Form f = getComponentForm(); @@ -2901,8 +3002,7 @@ private boolean snapToSafeAreaInternal() { int absX = getAbsoluteX() - safeAreaRoot.getAbsoluteX(); - int w = getWidth(); - int absX2 = absX + w; + int absX2 = absX + getWidth(); if (absX >= 0) { if (absX + paddingLeft < safeX1) { @@ -2916,8 +3016,7 @@ private boolean snapToSafeAreaInternal() { } int absY = getAbsoluteY() - safeAreaRoot.getAbsoluteY(); - int h = getHeight(); - int absY2 = absY + h; + int absY2 = absY + getHeight(); if (absY >= 0) { @@ -2932,55 +3031,29 @@ private boolean snapToSafeAreaInternal() { } boolean changed = false; - // The padding set here lives for one measurement: the caller hands it - // straight back to TmpInsets.restore, which suppresses its events too. - // Announcing it made Component.styleChanged queue a revalidate of the - // whole Form from inside its own layout, so every pass queued the next - // and a form with a safe-area container never stopped laying out. - boolean suppressEvents = style.isSuppressChangeEvents(); - style.setSuppressChangeEvents(true); - if (newPaddingTop != paddingTop || newPaddingBottom != paddingBottom) { - if (!hasScrollableYParentInternal()) { - changed = true; - - if (newPaddingTop != paddingTop) { - style.setPaddingUnitTop(Style.UNIT_TYPE_PIXELS); - style.setPaddingTop(newPaddingTop); - } - if (newPaddingBottom != paddingBottom) { - style.setPaddingUnitBottom(Style.UNIT_TYPE_PIXELS); - style.setPaddingBottom(newPaddingBottom); - } - - } + if ((newPaddingTop != paddingTop || newPaddingBottom != paddingBottom) + && !hasScrollableYParentInternal()) { + changed = true; + } else { + newPaddingTop = paddingTop; + newPaddingBottom = paddingBottom; } - - if (newPaddingLeft != paddingLeft || newPaddingRight != paddingRight) { - if (!hasScrollableXParentInternal()) { - changed = true; - - if (newPaddingLeft != paddingLeft) { - style.setPaddingUnitLeft(Style.UNIT_TYPE_PIXELS); - style.setPaddingLeft(newPaddingLeft); - } - if (newPaddingRight != paddingRight) { - style.setPaddingUnitRight(Style.UNIT_TYPE_PIXELS); - style.setPaddingRight(newPaddingRight); - } - - - } + if ((newPaddingLeft != paddingLeft || newPaddingRight != paddingRight) + && !hasScrollableXParentInternal()) { + changed = true; + } else { + newPaddingLeft = paddingLeft; + newPaddingRight = paddingRight; } - style.setSuppressChangeEvents(suppressEvents); - if (changed && !shouldCalcPreferredSize) { - // What the suppressed PADDING event did, minus its revalidateLater - // of the parent: this and every nested container measure and lay - // out again against the snapped padding. Without it a nested - // container kept a layout made for the unsnapped one. - setShouldCalcPreferredSize(true); + if (!changed) { + return null; } - return changed; - + int[] out = new int[4]; + out[0] = newPaddingTop; + out[1] = newPaddingBottom; + out[2] = newPaddingLeft; + out[3] = newPaddingRight; + return out; } // The safe-area snap's padding is suppressed (see snapToSafeAreaInternal). @@ -2992,9 +3065,108 @@ private boolean snapToSafeAreaInternal() { // painted it. Component.paintInternal now clears it too, so a peer's own // region repaints reach the screen without the form laying out each cycle. + /// The inset a safe-area container adds depends on where it sits, but its + /// preferred size is cached from wherever it sat when it was measured: not + /// yet placed, or placed by a parent that had moved since. A bottom tab bar + /// measured that way was sized for its content alone, and its layout then + /// padded the inset into that height and clipped the labels; whichever pass + /// next re-measured it put it back, so it jumped between the two as the + /// screen changed. + /// + /// Measurement therefore uses the inset the last layout applied, since a + /// position read mid-layout can be stale in either direction, and layout + /// compares the inset it applies with the one measured, having the size + /// computed again when they differ. Only an axis the parent sized from the + /// preferred size is compared: a stretched container's size never came from + /// its measurement, so a different inset there changes nothing. + private void checkMeasuredSafeArea(int[] applied) { + int[] measured = measuredSafeInsets; + if (measured == null) { + return; + } + if (shouldCalcPreferredSize) { + // Already to be measured again before anything reads it. + return; + } + // The final preferred size, after preferredSizeStr, setPreferredH, + // sameWidth and the like: what the parent really sized it from. + Dimension pref = getPreferredSize(); + boolean sizedY = getHeight() == pref.getHeight(); + boolean sizedX = getWidth() == pref.getWidth(); + boolean stale = false; + for (int i = 0; i < 4; i++) { + if (applied[i] != measured[i] && (i < 2 ? sizedY : sizedX)) { + stale = true; + } + } + if (!stale) { + // Forgiven only after two clean passes in a row, so a layout that + // alternates stale and clean still runs into the cap. + if (safeAreaCorrections > 0) { + safeAreaCleanPasses++; + if (safeAreaCleanPasses >= 2) { + safeAreaCorrections = 0; + safeAreaCleanPasses = 0; + } + } + return; + } + safeAreaCleanPasses = 0; + if (safeAreaCorrections >= 3) { + return; + } + safeAreaCorrections++; + measuredSafeInsets = null; + setShouldCalcPreferredSize(true); + safeAreaRelayout = true; + } + void doLayout() { + layoutNesting++; + boolean completed = false; + try { + doLayoutImpl(); + completed = true; + } finally { + layoutNesting--; + if (layoutNesting == 0 && !completed) { + safeAreaRelayout = false; + } + } + if (layoutNesting == 0 && safeAreaRelayout) { + safeAreaRelayout = false; + // Within this same revalidate where this is the root, so no frame + // is painted with the stale size; a container laid out on its own + // cannot resize itself, so its parent goes next. + Container parent = getParent(); + if (parent == null) { + // Each pass places the bar where the next measures it, so a + // change of size can take more than one; bounded, as is each + // container's own count of corrections. + layoutNesting++; + try { + for (int pass = 0; pass < 3; pass++) { + safeAreaRelayout = false; + shouldLayout = true; + layoutContainer(); + if (!safeAreaRelayout) { + break; + } + } + } finally { + layoutNesting--; + safeAreaRelayout = false; + } + } else { + parent.revalidateLater(); + } + } + } + + private void doLayoutImpl() { doLayoutDepth++; boolean restoreBounds = false; + int[] tmpSnappedFrom = null; if (safeArea && doLayoutDepth == 1) { // If this container is marked as a safe area // then we may need to add padding to make it *safe* @@ -3008,18 +3180,46 @@ void doLayout() { } Style s = getStyle(); tmpInsets.set(s); - restoreBounds = snapToSafeAreaInternal(); + int[] base = {s.getPaddingTop(), s.getPaddingBottom(), s.getPaddingLeftNoRTL(), s.getPaddingRightNoRTL()}; + int[] pad = safeAreaPadding(); + int[] applied = laidOutSafeInsets; + if (applied == null) { + applied = new int[4]; + } + for (int i = 0; i < 4; i++) { + applied[i] = pad == null ? 0 : pad[i] - base[i]; + } + laidOutSafeInsets = applied; + if (laidOutSafeContext == null) { + laidOutSafeContext = new int[safeAreaContext.length]; + } + System.arraycopy(safeAreaContext, 0, laidOutSafeContext, 0, safeAreaContext.length); + checkMeasuredSafeArea(applied); + restoreBounds = snapToSafeAreaInternal(pad, false); + tmpSnappedFrom = base; + } else { + laidOutSafeInsets = null; } } - layout.layoutContainer(this); - int count = getComponentCount(); - for (int i = 0; i < count; i++) { - Component c = getComponentAt(i); - if (c instanceof Container) { - ((Container) c).layoutContainer(); - } else { - c.laidOut(); + // Saved, not cleared: a nested layout of this container must not drop + // the outer one's snapped padding while that layout still holds it. + int[] outerSnappedFrom = snappedFromPadding; + if (restoreBounds) { + snappedFromPadding = tmpSnappedFrom; + } + try { + layout.layoutContainer(this); + int count = getComponentCount(); + for (int i = 0; i < count; i++) { + Component c = getComponentAt(i); + if (c instanceof Container) { + ((Container) c).layoutContainer(); + } else { + c.laidOut(); + } } + } finally { + snappedFromPadding = outerSnappedFrom; } if (restoreBounds && tmpInsets != null) { tmpInsets.restore(getStyle()); @@ -3631,8 +3831,75 @@ protected Dimension calcPreferredSize() { } Style s = getStyle(); calcTmpInsets.set(s); - restoreBounds = snapToSafeAreaInternal(); + int[] base = {s.getPaddingTop(), s.getPaddingBottom(), s.getPaddingLeftNoRTL(), s.getPaddingRightNoRTL()}; + int[] pad = safeAreaPadding(); + int[] laidOut = laidOutSafeInsets; + if (laidOut != null && (safeAreaContext[0] < 0 || laidOutSafeContext == null + || !sameSafeAreaContext(laidOutSafeContext))) { + // Hidden, rotated, a new safe area or another surface: the + // last layout's inset says nothing about this one. + laidOut = null; + } + int[] snappedFrom = snappedFromPadding; + if (snappedFrom != null) { + // Measured from inside its own layout, which holds the + // snapped padding: the inset is already in the style, so + // it is counted once, against the padding it replaced. + laidOut = new int[4]; + for (int i = 0; i < 4; i++) { + laidOut[i] = Math.max(0, base[i] - snappedFrom[i]); + base[i] = snappedFrom[i]; + } + } + int[] measured = measuredSafeInsets; + if (measured == null) { + measured = new int[4]; + } + boolean any = false; + for (int i = 0; i < 4; i++) { + // Once laid out, the inset that layout applied: the + // position read here can be stale mid-layout in either + // direction, and layout corrects a wrong one at once. + if (laidOut != null) { + measured[i] = laidOut[i]; + } else { + measured[i] = pad == null ? 0 : pad[i] - base[i]; + } + if (measured[i] != 0) { + any = true; + } + } + measuredSafeInsets = measured; + if (laidOut == null && safeAreaContext[0] >= 0) { + // The first reading in this context stands until a layout + // replaces it. Measured again before then, at a position + // the parent has already moved on from, the bar could + // read an inset that holds only for the old size. + int[] first = laidOutSafeInsets; + if (first == null) { + first = new int[4]; + } + System.arraycopy(measured, 0, first, 0, 4); + laidOutSafeInsets = first; + if (laidOutSafeContext == null) { + laidOutSafeContext = new int[safeAreaContext.length]; + } + System.arraycopy(safeAreaContext, 0, laidOutSafeContext, 0, safeAreaContext.length); + } + if (any) { + pad = new int[4]; + for (int i = 0; i < 4; i++) { + pad[i] = base[i] + measured[i]; + } + restoreBounds = snapToSafeAreaInternal(pad, true); + } + } else { + measuredSafeInsets = null; } + } else if (calcPreferredSizeDepth == 1) { + // Not placed yet: nothing was added, and the first layout judges + // whether something should have been. + measuredSafeInsets = safeArea && (getWidth() <= 0 || getHeight() <= 0) ? new int[4] : null; } Dimension d = layout.getPreferredSize(this); diff --git a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java index 541f9d89d2d..91d4f5c29d2 100644 --- a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java +++ b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java @@ -563,6 +563,7 @@ private static Font parseFont(String fontStr) { /// where we would be out of options if something like that happens. Effectively this is the same as writing: /// /// ```java + /// String resourceFile = "/theme"; /// Resources theme = null; /// try { /// theme = Resources.openLayered(resourceFile); diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/SafeAreaBottomBarStabilityTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/SafeAreaBottomBarStabilityTest.java new file mode 100644 index 00000000000..ec16677f9b6 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/SafeAreaBottomBarStabilityTest.java @@ -0,0 +1,345 @@ +/* + * Copyright (c) 2026, 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.ui; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.ui.geom.Rectangle; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.layouts.Layout; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// A safe-area tab bar at the bottom of a screen must keep one height across +/// revalidates. It used to flip between two layouts: one sized for its content +/// plus the bottom inset, and one sized for the content alone with the inset +/// padded into it, clipping the labels. Which one a pass produced depended on +/// the bounds the bar happened to have when it was measured. +/// +/// The fix has layout re-judge the inset and lay out again when the +/// measurement was stale, so the other tests here hold it to doing that only +/// when it must: a settled bar, a stretched safe-area pane and a scrollable one +/// are laid out exactly once per revalidate. +class SafeAreaBottomBarStabilityTest extends UITestBase { + + private static final int TOP_INSET = 47; + private static final int INSET = 34; + + /// Counts its layouts, to tell a settled layout from one that keeps asking + /// for another pass. + private static final class Counting extends Container { + int layouts; + + Counting(Layout l) { + super(l); + } + + @Override + public void laidOut() { + super.laidOut(); + layouts++; + } + } + + private Container body; + private Counting tabs; + private Button cart; + private Container top; + + private Form notched() { + Form f = Display.getInstance().getCurrent(); + int w = Display.getInstance().getDisplayWidth(); + int h = Display.getInstance().getDisplayHeight(); + implementation.setDisplaySafeArea(new Rectangle(0, TOP_INSET, w, h - TOP_INSET - INSET)); + f.setLayout(new BorderLayout()); + return f; + } + + private Form build() { + Form f = notched(); + Container screen = new Container(new BorderLayout()); + top = new Container(BoxLayout.y()); + top.add(new Label("Deliver to")); + top.setSafeArea(true); + screen.add(BorderLayout.NORTH, top); + body = new Container(new BorderLayout()); + screen.add(BorderLayout.CENTER, body); + Container south = new Container(BoxLayout.y()); + cart = new Button("View cart"); + cart.setHidden(true); + cart.setVisible(false); + south.add(cart); + tabs = new Counting(new GridLayout(1, 4)); + for (String s : new String[]{"Home", "Search", "Orders", "Favorites"}) { + tabs.add(new Button(s)); + } + tabs.setSafeArea(true); + south.add(tabs); + screen.add(BorderLayout.SOUTH, south); + f.add(BorderLayout.CENTER, screen); + f.show(); + f.revalidate(); + return f; + } + + private void switchTo(Form f, int rows) { + body.removeAll(); + Container list = new Container(BoxLayout.y()); + for (int i = 0; i < rows; i++) { + list.add(new Label("Row " + i)); + } + list.setScrollableY(true); + body.add(BorderLayout.CENTER, list); + f.revalidate(); + } + + private int contentHeight() { + int max = 0; + for (Component c : tabs) { + max = Math.max(max, c.getOuterHeight()); + } + return max + tabs.getStyle().getVerticalPadding(); + } + + @FormTest + void bottomBarKeepsItsHeightAcrossTabSwitches() { + Form f = build(); + int expected = contentHeight() + INSET; + int[] rows = {3, 40, 0, 12, 1, 60, 5}; + for (int i = 0; i < rows.length; i++) { + switchTo(f, rows[i]); + assertEquals(expected, tabs.getHeight(), "tab bar height after switch " + i); + assertEquals(f.getContentPane().getAbsoluteY() + f.getContentPane().getHeight(), + tabs.getAbsoluteY() + tabs.getHeight(), "tab bar bottom after switch " + i); + // The header is nowhere near the bottom edge: no bottom inset. + assertEquals(top.getComponentAt(0).getOuterHeight() + top.getStyle().getVerticalPadding() + + Math.max(0, TOP_INSET - top.getAbsoluteY()), top.getHeight(), + "header height after switch " + i); + } + } + + @FormTest + void bottomBarKeepsItsHeightWhenTheCartAppearsAndGoes() { + Form f = build(); + int expected = contentHeight() + INSET; + for (int i = 0; i < 6; i++) { + boolean show = i % 2 == 0; + cart.setHidden(!show); + cart.setVisible(show); + f.revalidate(); + assertEquals(expected, tabs.getHeight(), "tab bar height, cart shown=" + show); + } + } + + @FormTest + void aSettledBottomBarIsLaidOutOncePerRevalidate() { + Form f = build(); + switchTo(f, 4); + int height = tabs.getHeight(); + for (int i = 0; i < 3; i++) { + tabs.layouts = 0; + f.revalidate(); + assertEquals(1, tabs.layouts, "settled tab bar, revalidate " + i); + assertEquals(height, tabs.getHeight()); + } + } + + @FormTest + void aStretchedSafeAreaPaneIsLaidOutOncePerRevalidate() { + Form f = notched(); + Counting pane = new Counting(new BorderLayout()); + pane.setSafeArea(true); + Label content = new Label("Short content"); + pane.add(BorderLayout.NORTH, content); + f.add(BorderLayout.CENTER, pane); + f.show(); + f.revalidate(); + for (int i = 0; i < 3; i++) { + pane.layouts = 0; + f.revalidate(); + assertEquals(1, pane.layouts, "stretched safe-area pane, revalidate " + i); + } + // Stretched to the bottom edge, its content still stays clear of it. + int safeBottom = Display.getInstance().getDisplayHeight() - INSET; + assertTrue(pane.getAbsoluteY() + pane.getHeight() > safeBottom, "pane reaches the unsafe strip"); + } + + @FormTest + void aScrollableSafeAreaPaneKeepsTheInsetInItsScrollSize() { + Form f = notched(); + Counting pane = new Counting(BoxLayout.y()); + pane.setScrollableY(true); + pane.setSafeArea(true); + for (int i = 0; i < 200; i++) { + pane.add(new Label("Row " + i)); + } + f.add(BorderLayout.CENTER, pane); + f.show(); + f.revalidate(); + int content = 0; + for (Component c : pane) { + content += c.getOuterHeight(); + } + int scroll = pane.getScrollDimension().getHeight(); + assertTrue(scroll >= content + INSET, + "scroll size " + scroll + " must let the last row clear the inset (content " + content + ")"); + pane.layouts = 0; + f.revalidate(); + assertEquals(1, pane.layouts, "scrollable safe-area pane, one layout per revalidate"); + } + + @FormTest + void bottomBarDropsTheInsetWhenTheSafeAreaGoesAway() { + Form f = build(); + int content = contentHeight(); + assertEquals(content + INSET, tabs.getHeight()); + int w = Display.getInstance().getDisplayWidth(); + int h = Display.getInstance().getDisplayHeight(); + implementation.setDisplaySafeArea(new Rectangle(0, 0, w, h)); + for (int i = 0; i < 2; i++) { + switchTo(f, 3 + i); + } + assertEquals(content, tabs.getHeight(), "no inset once the display has none"); + } + + @FormTest + void aBarMovedToTheTopLosesTheBottomInset() { + Form f = build(); + int content = contentHeight(); + Container south = tabs.getParent(); + south.removeComponent(tabs); + top.add(tabs); + f.revalidate(); + f.revalidate(); + assertEquals(content, tabs.getHeight(), "a bar at the top has no bottom inset"); + } + + @FormTest + void aBarNoLongerAtTheEdgeDropsTheInsetInOneRevalidate() { + // Measured at the edge with the inset, then pushed up by a footer: the + // first layout applies none, so that same revalidate must re-measure. + Form f = build(); + int content = contentHeight(); + assertEquals(content + INSET, tabs.getHeight()); + // Taller than the inset, so the bar clears the unsafe edge entirely. + Label footer = new Label("Footer"); + footer.setPreferredH(INSET * 2); + tabs.getParent().add(footer); + f.revalidate(); + assertEquals(content, tabs.getHeight(), "the bar keeps an inset it no longer gets"); + } + + @FormTest + void aBarPartlyOverTheEdgeGetsOnlyTheOverlap() { + // A footer shorter than the inset leaves the bar over the unsafe edge + // by the difference, and that is all the padding it should take. + Form f = build(); + int content = contentHeight(); + Label footer = new Label("Footer"); + footer.setPreferredH(INSET - 8); + tabs.getParent().add(footer); + f.revalidate(); + int safeLine = Display.getInstance().getDisplayHeight() - INSET; + int overlap = tabs.getAbsoluteY() + tabs.getHeight() - safeLine; + assertTrue(overlap > 0 && overlap < INSET, "the bar ends inside the unsafe edge: " + overlap); + assertEquals(content + overlap, tabs.getHeight(), "the bar's inset is the part of it over the edge"); + int settled = tabs.getHeight(); + f.revalidate(); + assertEquals(settled, tabs.getHeight(), "and it stays there"); + } + + @FormTest + void aBarTakesANewInsetInOneRevalidate() { + // Laid out with no inset, then the display gains one (a rotation, a + // safe area reported late): the inset that last layout applied must not + // hold the new measurement at zero. + Form f = build(); + int content = contentHeight(); + int w = Display.getInstance().getDisplayWidth(); + int h = Display.getInstance().getDisplayHeight(); + implementation.setDisplaySafeArea(new Rectangle(0, 0, w, h)); + f.revalidate(); + f.revalidate(); + assertEquals(content, tabs.getHeight()); + implementation.setDisplaySafeArea(new Rectangle(0, TOP_INSET, w, h - TOP_INSET - INSET)); + f.revalidate(); + assertEquals(content + INSET, tabs.getHeight(), "the bar gets the new inset in the same revalidate"); + } + + @FormTest + void aBarMeasuredDuringItsOwnLayoutCountsTheInsetOnce() { + // A bottom Tabs bar is measured again from inside its own layout, when + // its style already holds the snapped padding; adding the inset to + // that again made its preferred size an inset too tall. + notched(); + Form f = Display.getInstance().getCurrent(); + Tabs t = new Tabs(Component.BOTTOM); + t.addTab("A", new Label("first")); + t.addTab("B", new Label("second")); + f.add(BorderLayout.CENTER, t); + f.revalidate(); + t.setSelectedIndex(1); + f.revalidate(); + Container bar = t.getTabsContainer(); + int content = 0; + for (Component c : bar) { + content = Math.max(content, c.getOuterHeight()); + } + int overlap = bar.getAbsoluteY() + bar.getHeight() - (Display.getInstance().getDisplayHeight() - INSET); + assertTrue(overlap > 0, "the bar reaches into the unsafe edge: " + overlap); + assertEquals(content + bar.getStyle().getVerticalPadding() + overlap, bar.getPreferredH(), + "the bar is measured with one inset"); + assertEquals(bar.getPreferredH(), bar.getHeight()); + } + + @FormTest + void aSafeAreaBarInsideASafeAreaPaneGetsOneInset() { + Form f = notched(); + Container pane = new Container(new BorderLayout()); + pane.setSafeArea(true); + pane.add(BorderLayout.CENTER, new Label("Body")); + Counting bar = new Counting(new GridLayout(1, 2)); + bar.add(new Button("A")); + bar.add(new Button("B")); + bar.setSafeArea(true); + pane.add(BorderLayout.SOUTH, bar); + f.add(BorderLayout.CENTER, pane); + f.show(); + f.revalidate(); + int content = 0; + for (Component c : bar) { + content = Math.max(content, c.getOuterHeight()); + } + content += bar.getStyle().getVerticalPadding(); + for (int i = 0; i < 3; i++) { + bar.layouts = 0; + f.revalidate(); + assertEquals(1, bar.layouts, "nested bar, revalidate " + i); + assertEquals(content, bar.getHeight(), "the pane already pads the inset; the bar adds none"); + } + } +} diff --git a/scripts/android/screenshots/SheetSlideUpAnimationScreenshotTest.png b/scripts/android/screenshots/SheetSlideUpAnimationScreenshotTest.png index 51dfb4e85c8..a4ae8563275 100644 Binary files a/scripts/android/screenshots/SheetSlideUpAnimationScreenshotTest.png and b/scripts/android/screenshots/SheetSlideUpAnimationScreenshotTest.png differ diff --git a/scripts/doc-snippet-baseline.txt b/scripts/doc-snippet-baseline.txt index 0693af2cadc..8543a681e35 100644 --- a/scripts/doc-snippet-baseline.txt +++ b/scripts/doc-snippet-baseline.txt @@ -321,7 +321,6 @@ CodenameOne/src/com/codename1/ui/list/CellRenderer.java#5eca0098701e CodenameOne/src/com/codename1/ui/list/ListCellRenderer.java#500e2416fe67 CodenameOne/src/com/codename1/ui/list/ListModel.java#8d4d9b014972 CodenameOne/src/com/codename1/ui/list/ListModel.java#d0795f2aa202 -CodenameOne/src/com/codename1/ui/plaf/UIManager.java#6aaadca1b2ce CodenameOne/src/com/codename1/util/regex/RE.java#32627ff643a4 CodenameOne/src/com/codename1/vpn/profile/Vpn.java#aaa2e660926c CodenameOne/src/com/codename1/vpn/profile/VpnProfile.java#a1cc6689b4e5 diff --git a/scripts/ios/screenshots-metal-27/AnimateHierarchyScreenshotTest.png b/scripts/ios/screenshots-metal-27/AnimateHierarchyScreenshotTest.png index ccdc126c2f3..87c3f5b0e52 100644 Binary files a/scripts/ios/screenshots-metal-27/AnimateHierarchyScreenshotTest.png and b/scripts/ios/screenshots-metal-27/AnimateHierarchyScreenshotTest.png differ diff --git a/scripts/ios/screenshots-metal-27/AnimateLayoutScreenshotTest.png b/scripts/ios/screenshots-metal-27/AnimateLayoutScreenshotTest.png index 02f66429db4..311e2fe129d 100644 Binary files a/scripts/ios/screenshots-metal-27/AnimateLayoutScreenshotTest.png and b/scripts/ios/screenshots-metal-27/AnimateLayoutScreenshotTest.png differ diff --git a/scripts/ios/screenshots-metal-27/AnimateUnlayoutScreenshotTest.png b/scripts/ios/screenshots-metal-27/AnimateUnlayoutScreenshotTest.png index 09e96e0cfab..29bb7db36b1 100644 Binary files a/scripts/ios/screenshots-metal-27/AnimateUnlayoutScreenshotTest.png and b/scripts/ios/screenshots-metal-27/AnimateUnlayoutScreenshotTest.png differ diff --git a/scripts/ios/screenshots-metal-27/MorphTransitionScrolledSourceTest.png b/scripts/ios/screenshots-metal-27/MorphTransitionScrolledSourceTest.png index 555d2876682..57ab94d3749 100644 Binary files a/scripts/ios/screenshots-metal-27/MorphTransitionScrolledSourceTest.png and b/scripts/ios/screenshots-metal-27/MorphTransitionScrolledSourceTest.png differ diff --git a/scripts/ios/screenshots-metal-27/PullToRefreshSpinnerScreenshotTest.png b/scripts/ios/screenshots-metal-27/PullToRefreshSpinnerScreenshotTest.png index 219284304b3..0a6a357b6b7 100644 Binary files a/scripts/ios/screenshots-metal-27/PullToRefreshSpinnerScreenshotTest.png and b/scripts/ios/screenshots-metal-27/PullToRefreshSpinnerScreenshotTest.png differ diff --git a/scripts/ios/screenshots-metal-27/SheetSlideUpAnimationScreenshotTest.png b/scripts/ios/screenshots-metal-27/SheetSlideUpAnimationScreenshotTest.png index fc19c7c96ed..08f6d9af0f7 100644 Binary files a/scripts/ios/screenshots-metal-27/SheetSlideUpAnimationScreenshotTest.png and b/scripts/ios/screenshots-metal-27/SheetSlideUpAnimationScreenshotTest.png differ diff --git a/scripts/ios/screenshots-metal-27/SmoothScrollScreenshotTest.png b/scripts/ios/screenshots-metal-27/SmoothScrollScreenshotTest.png index c669b115984..f0cbc9c8d6c 100644 Binary files a/scripts/ios/screenshots-metal-27/SmoothScrollScreenshotTest.png and b/scripts/ios/screenshots-metal-27/SmoothScrollScreenshotTest.png differ diff --git a/scripts/ios/screenshots-metal-27/StatusBarTapDiagnosticScreenshotTest.png b/scripts/ios/screenshots-metal-27/StatusBarTapDiagnosticScreenshotTest.png index 6d1973f8fee..dbe2f251aba 100644 Binary files a/scripts/ios/screenshots-metal-27/StatusBarTapDiagnosticScreenshotTest.png and b/scripts/ios/screenshots-metal-27/StatusBarTapDiagnosticScreenshotTest.png differ diff --git a/scripts/ios/screenshots-metal-27/StickyHeaderFadeTransitionScreenshotTest.png b/scripts/ios/screenshots-metal-27/StickyHeaderFadeTransitionScreenshotTest.png index ed5f92edd49..6fee171abc0 100644 Binary files a/scripts/ios/screenshots-metal-27/StickyHeaderFadeTransitionScreenshotTest.png and b/scripts/ios/screenshots-metal-27/StickyHeaderFadeTransitionScreenshotTest.png differ diff --git a/scripts/ios/screenshots-metal-27/StickyHeaderScreenshotTest.png b/scripts/ios/screenshots-metal-27/StickyHeaderScreenshotTest.png index 3023e0a7a15..f0416e5750b 100644 Binary files a/scripts/ios/screenshots-metal-27/StickyHeaderScreenshotTest.png and b/scripts/ios/screenshots-metal-27/StickyHeaderScreenshotTest.png differ diff --git a/scripts/ios/screenshots-metal-27/StickyHeaderSlideTransitionScreenshotTest.png b/scripts/ios/screenshots-metal-27/StickyHeaderSlideTransitionScreenshotTest.png index 7082063ce7c..073647a7d0e 100644 Binary files a/scripts/ios/screenshots-metal-27/StickyHeaderSlideTransitionScreenshotTest.png and b/scripts/ios/screenshots-metal-27/StickyHeaderSlideTransitionScreenshotTest.png differ diff --git a/scripts/ios/screenshots-metal-27/TabsAnimatedIndicatorScreenshotTest.png b/scripts/ios/screenshots-metal-27/TabsAnimatedIndicatorScreenshotTest.png index cb501c51a00..293bd34e571 100644 Binary files a/scripts/ios/screenshots-metal-27/TabsAnimatedIndicatorScreenshotTest.png and b/scripts/ios/screenshots-metal-27/TabsAnimatedIndicatorScreenshotTest.png differ diff --git a/scripts/ios/screenshots-metal-27/TensileBounceScreenshotTest.png b/scripts/ios/screenshots-metal-27/TensileBounceScreenshotTest.png index 8f62f22eab1..a728f95548c 100644 Binary files a/scripts/ios/screenshots-metal-27/TensileBounceScreenshotTest.png and b/scripts/ios/screenshots-metal-27/TensileBounceScreenshotTest.png differ