diff --git a/CodenameOne/src/com/codename1/ui/Sheet.java b/CodenameOne/src/com/codename1/ui/Sheet.java index 88de26b669..a053f72502 100644 --- a/CodenameOne/src/com/codename1/ui/Sheet.java +++ b/CodenameOne/src/com/codename1/ui/Sheet.java @@ -23,6 +23,8 @@ package com.codename1.ui; import com.codename1.ui.ComponentSelector.ComponentClosure; +import com.codename1.ui.animations.ComponentAnimation; +import com.codename1.ui.animations.Motion; import com.codename1.ui.events.ActionEvent; import com.codename1.ui.events.ActionListener; import com.codename1.ui.geom.Rectangle; @@ -153,6 +155,176 @@ public void actionPerformed(ActionEvent evt) { } }; + private boolean swipeToDismissEnabled = true; + private boolean dragging; + private int dragStartPointerX; + private int dragStartPointerY; + private int dragStartSheetX; + private int dragStartSheetY; + private long lastDragTime; + private int lastDragPointerX; + private int lastDragPointerY; + private float dragVelocity; + private boolean dismissAnimating; + private final ActionListener formSwipePressedListener = new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + if (!swipeToDismissEnabled || !allowClose || dismissAnimating) { + return; + } + int x = evt.getX(); + int y = evt.getY(); + // Drag to dismiss is initiated only on the title bar (excluding the + // back button and commands container, which are interactive controls) + // so it does not interfere with content scrolling or button taps. + if (!titleBar.contains(x, y)) { + return; + } + if (backButton.isVisible() && backButton.contains(x, y)) { + return; + } + if (commandsContainer.contains(x, y)) { + return; + } + dragging = true; + dragStartPointerX = x; + dragStartPointerY = y; + dragStartSheetX = getX(); + dragStartSheetY = getY(); + lastDragPointerX = x; + lastDragPointerY = y; + lastDragTime = System.currentTimeMillis(); + dragVelocity = 0f; + } + }; + private final ActionListener formSwipeDraggedListener = new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + if (!dragging) { + return; + } + int x = evt.getX(); + int y = evt.getY(); + int dx = x - dragStartPointerX; + int dy = y - dragStartPointerY; + int positionInt = getPositionInt(); + boolean moved = false; + switch (positionInt) { + case S: + case C: + if (dy > 0) { + setY(dragStartSheetY + dy); + moved = true; + } else { + setY(dragStartSheetY); + } + break; + case N: + if (dy < 0) { + setY(dragStartSheetY + dy); + moved = true; + } else { + setY(dragStartSheetY); + } + break; + case E: + if (dx > 0) { + setX(dragStartSheetX + dx); + moved = true; + } else { + setX(dragStartSheetX); + } + break; + case W: + if (dx < 0) { + setX(dragStartSheetX + dx); + moved = true; + } else { + setX(dragStartSheetX); + } + break; + default: + break; + } + long now = System.currentTimeMillis(); + // Treat sub-millisecond gaps as 1ms so a fast successive drag + // event still produces a finite velocity reading rather than + // silently keeping the previous value (which would be zero on + // the first sample). + long elapsed = Math.max(1, now - lastDragTime); + int dragDelta; + if (positionInt == E || positionInt == W) { + dragDelta = x - lastDragPointerX; + } else { + dragDelta = y - lastDragPointerY; + } + dragVelocity = dragDelta * 1000f / elapsed; + lastDragPointerX = x; + lastDragPointerY = y; + lastDragTime = now; + if (moved) { + evt.consume(); + Container parent = getParent(); + if (parent != null) { + parent.repaint(); + } + } + } + }; + private final ActionListener formSwipeReleasedListener = new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + if (!dragging) { + return; + } + dragging = false; + int positionInt = getPositionInt(); + int distance; + int dimension; + float velocity = dragVelocity; + switch (positionInt) { + case S: + case C: + distance = getY() - dragStartSheetY; + dimension = getHeight(); + break; + case N: + distance = dragStartSheetY - getY(); + dimension = getHeight(); + velocity = -velocity; + break; + case E: + distance = getX() - dragStartSheetX; + dimension = getWidth(); + break; + case W: + distance = dragStartSheetX - getX(); + dimension = getWidth(); + velocity = -velocity; + break; + default: + distance = 0; + dimension = 1; + break; + } + // A drag past one third of the sheet, or a sufficiently fast flick + // (~50 dips/sec) in the dismiss direction, dismisses the sheet. + // Otherwise we snap back to the resting position. + boolean horizontal = positionInt == E || positionInt == W; + int flickThreshold = Display.getInstance().convertToPixels(50, horizontal); + boolean dismiss = distance > dimension / 3 || velocity > flickThreshold; + if (dismiss) { + evt.consume(); + animateDismissFromDrag(DEFAULT_TRANSITION_DURATION); + } else if (distance > 0) { + evt.consume(); + Container parent = getParent(); + if (parent != null) { + parent.animateLayout(DEFAULT_TRANSITION_DURATION); + } + } + } + }; private boolean allowClose = true; /// The position on the screen where the sheet is displayed on phones. /// One of `BorderLayout#CENTER`, `BorderLayout#NORTH`, `BorderLayout#SOUTH`, @@ -364,8 +536,11 @@ public void setAllowClose(boolean allowClose) { this.allowClose = allowClose; if (!allowClose && isInitialized()) { form.removePointerPressedListener(formPointerListener); + detachSwipeListeners(form); + dragging = false; } else if (allowClose && isInitialized()) { form.addPointerPressedListener(formPointerListener); + attachSwipeListeners(form); } if (parentSheet == null) { backButton.setVisible(allowClose); @@ -374,6 +549,60 @@ public void setAllowClose(boolean allowClose) { } } + /// Checks whether this sheet can be dismissed by swiping it toward the + /// edge of the screen (e.g. swiping down for a south-positioned sheet). + /// + /// #### Returns + /// + /// True if swipe-to-dismiss is enabled. + /// + /// #### Since + /// + /// 8.0 + public boolean isSwipeToDismissEnabled() { + return swipeToDismissEnabled; + } + + /// Enables or disables the swipe-to-dismiss gesture. When enabled (the default), + /// a downward drag on the sheet's title bar (or the corresponding direction for + /// other positions) will close the sheet. The gesture is also subject to + /// {@link #isAllowClose()}; if `allowClose` is false the gesture is disabled + /// regardless of this flag. + /// + /// #### Parameters + /// + /// - `swipeToDismissEnabled`: True to enable the swipe-to-dismiss gesture, false to disable it. + /// + /// #### Since + /// + /// 8.0 + public void setSwipeToDismissEnabled(boolean swipeToDismissEnabled) { + if (this.swipeToDismissEnabled != swipeToDismissEnabled) { + this.swipeToDismissEnabled = swipeToDismissEnabled; + if (!swipeToDismissEnabled) { + dragging = false; + } + } + } + + private void attachSwipeListeners(Form f) { + if (f == null) { + return; + } + f.addPointerPressedListener(formSwipePressedListener); + f.addPointerDraggedListener(formSwipeDraggedListener); + f.addPointerReleasedListener(formSwipeReleasedListener); + } + + private void detachSwipeListeners(Form f) { + if (f == null) { + return; + } + f.removePointerPressedListener(formSwipePressedListener); + f.removePointerDraggedListener(formSwipeDraggedListener); + f.removePointerReleasedListener(formSwipeReleasedListener); + } + /// Gets the content pane of the sheet. All sheet content should be added to the content pane /// and not directly to the sheet. /// @@ -953,6 +1182,60 @@ public void run() { } + /// Animates the sheet from its current (mid-drag) position to the off-screen + /// hidden position and then disposes it. Unlike `#hide(int)` this does not + /// snap back to the layout-resting position before sliding out, which would + /// produce a visible jump after the user releases their finger. + private void animateDismissFromDrag(final int duration) { + final Container cnt = getParent(); + if (cnt == null) { + // Nothing to animate; fall through to the standard hide path. + hide(duration); + return; + } + Form f = getComponentForm(); + if (f == null) { + hide(duration); + return; + } + dismissAnimating = true; + final int fromX = getX(); + final int fromY = getY(); + final int toX = getHiddenX(cnt); + final int toY = getHiddenY(cnt); + final Motion xMotion = Motion.createEaseOutMotion(fromX, toX, duration); + final Motion yMotion = Motion.createEaseOutMotion(fromY, toY, duration); + xMotion.start(); + yMotion.start(); + ComponentAnimation animation = new ComponentAnimation() { + @Override + public boolean isInProgress() { + return !(xMotion.isFinished() && yMotion.isFinished()); + } + + @Override + protected void updateState() { + setX(xMotion.getValue()); + setY(yMotion.getValue()); + cnt.repaint(); + } + }; + Runnable onComplete = new Runnable() { + @Override + public void run() { + Container parent = cnt.getParent(); + if (parent != null && cnt.getComponentForm() != null) { + cnt.remove(); + parent.getComponentForm().revalidateLater(); + fireCloseEvent(true); + stopTrackingBounds(); + } + dismissAnimating = false; + } + }; + f.getAnimationManager().addAnimation(animation, onComplete); + } + @Override public void setX(int x) { super.setX(x); @@ -992,6 +1275,7 @@ protected void initComponent() { form = getComponentForm(); if (form != null && allowClose) { form.addPointerPressedListener(formPointerListener); + attachSwipeListeners(form); } } @@ -999,8 +1283,10 @@ protected void initComponent() { protected void deinitialize() { if (form != null) { form.removePointerPressedListener(formPointerListener); + detachSwipeListeners(form); form = null; } + dragging = false; super.deinitialize(); } diff --git a/Ports/JavaScriptPort/src/main/webapp/port.js b/Ports/JavaScriptPort/src/main/webapp/port.js index a84ce4e455..02a398cec0 100644 --- a/Ports/JavaScriptPort/src/main/webapp/port.js +++ b/Ports/JavaScriptPort/src/main/webapp/port.js @@ -2818,6 +2818,7 @@ const cn1ssForcedTimeoutTestClasses = Object.freeze({ "com_codenameone_examples_hellocodenameone_tests_ComponentReplaceSlideScreenshotTest": "animationGrid", "com_codenameone_examples_hellocodenameone_tests_ComponentReplaceFlipScreenshotTest": "animationGrid", "com_codenameone_examples_hellocodenameone_tests_MotionShowcaseScreenshotTest": "animationGrid", + "com_codenameone_examples_hellocodenameone_tests_SheetSlideUpAnimationScreenshotTest": "animationGrid", // Screenshot-emitting tests whose chunk streams the JS port truncates // under console.log line drops. Cn1ssChunkTools's gap detection (added // in 963dd5af) correctly fails the resulting partial PNGs; force-finalise @@ -2900,6 +2901,7 @@ const cn1ssForcedTimeoutTestNames = Object.freeze({ "ComponentReplaceSlideScreenshotTest": "animationGrid", "ComponentReplaceFlipScreenshotTest": "animationGrid", "MotionShowcaseScreenshotTest": "animationGrid", + "SheetSlideUpAnimationScreenshotTest": "animationGrid", // Screenshot-emitting tests whose chunk streams the JS port truncates // under console.log line drops. Cn1ssChunkTools's gap detection (added // in 963dd5af) correctly fails the resulting partial PNGs; force-finalise diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/SheetSwipeToDismissTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/SheetSwipeToDismissTest.java new file mode 100644 index 0000000000..02f92671a1 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/SheetSwipeToDismissTest.java @@ -0,0 +1,276 @@ +package com.codename1.ui; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.ui.layouts.BorderLayout; + +import java.lang.reflect.Field; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +class SheetSwipeToDismissTest extends UITestBase { + + @FormTest + void swipeToDismissEnabledByDefault() { + Sheet sheet = new Sheet(null, "Test"); + assertTrue(sheet.isSwipeToDismissEnabled(), + "Swipe-to-dismiss should be enabled by default"); + } + + @FormTest + void canToggleSwipeToDismiss() { + Sheet sheet = new Sheet(null, "Test"); + sheet.setSwipeToDismissEnabled(false); + assertFalse(sheet.isSwipeToDismissEnabled(), + "Swipe-to-dismiss should be disabled after setSwipeToDismissEnabled(false)"); + sheet.setSwipeToDismissEnabled(true); + assertTrue(sheet.isSwipeToDismissEnabled(), + "Swipe-to-dismiss should be re-enabled after setSwipeToDismissEnabled(true)"); + } + + @FormTest + void swipeDownPastThresholdDismissesSheet() throws Exception { + Form form = showFormWithSheet("Drag To Dismiss"); + Sheet sheet = Sheet.getCurrentSheet(); + assertNotNull(sheet, "Sheet should be visible before drag"); + + Container titleBar = getTitleBar(sheet); + int x = titleBar.getAbsoluteX() + titleBar.getWidth() / 2; + int startY = titleBar.getAbsoluteY() + titleBar.getHeight() / 2; + int dragDistance = (int) (sheet.getHeight() * 0.6); + + dragSheet(x, startY, 0, dragDistance, 5); + implementation.dispatchPointerRelease(x, startY + dragDistance); + flushSerialCalls(); + + awaitAnimations(form); + + assertNull(Sheet.getCurrentSheet(), + "Sheet should be dismissed when dragged past the dismiss threshold"); + } + + @FormTest + void fastFlickDismissesSheetEvenBelowDistanceThreshold() throws Exception { + Form form = showFormWithSheet("Flick Dismiss"); + Sheet sheet = Sheet.getCurrentSheet(); + assertNotNull(sheet); + + Container titleBar = getTitleBar(sheet); + int x = titleBar.getAbsoluteX() + titleBar.getWidth() / 2; + int startY = titleBar.getAbsoluteY() + titleBar.getHeight() / 2; + // Sub-threshold distance, but a fast last-frame movement so velocity + // (~ pixels per second) sails past the flick threshold. + int finalY = startY + Math.max(2, sheet.getHeight() / 8); + + implementation.dispatchPointerPress(x, startY); + implementation.setHasDragStarted(true); + flushSerialCalls(); + // Sleep ensures elapsed > 0 for the velocity calc; the drag still + // produces a velocity well above the (low) test-implementation flick + // threshold (convertToPixels returns dipCount-as-pixels in tests), + // so dismiss should fire via the velocity path despite the sub- + // threshold absolute distance. + sleepQuietly(20); + implementation.dispatchPointerDrag(x, finalY); + implementation.dispatchPointerRelease(x, finalY); + flushSerialCalls(); + + awaitAnimations(form); + + assertNull(Sheet.getCurrentSheet(), + "A fast flick should dismiss the sheet via the velocity threshold"); + } + + @FormTest + void smallSwipeSnapsBackInsteadOfDismissing() throws Exception { + Form form = showFormWithSheet("Snap Back"); + Sheet sheet = Sheet.getCurrentSheet(); + int restingY = sheet.getY(); + + Container titleBar = getTitleBar(sheet); + int x = titleBar.getAbsoluteX() + titleBar.getWidth() / 2; + int startY = titleBar.getAbsoluteY() + titleBar.getHeight() / 2; + // Drag well below the 1/3 dismiss threshold. + int dragDistance = Math.max(1, sheet.getHeight() / 10); + + dragSheet(x, startY, 0, dragDistance, 3); + implementation.dispatchPointerRelease(x, startY + dragDistance); + flushSerialCalls(); + + awaitAnimations(form); + + assertSame(sheet, Sheet.getCurrentSheet(), + "Sheet should still be visible after a sub-threshold drag"); + assertEquals(restingY, sheet.getY(), + "Sheet should snap back to its original Y after a sub-threshold drag"); + } + + @FormTest + void dragInWrongDirectionDoesNotDismissSouthSheet() throws Exception { + Form form = showFormWithSheet("Wrong Direction"); + Sheet sheet = Sheet.getCurrentSheet(); + int restingY = sheet.getY(); + + Container titleBar = getTitleBar(sheet); + int x = titleBar.getAbsoluteX() + titleBar.getWidth() / 2; + int startY = titleBar.getAbsoluteY() + titleBar.getHeight() / 2; + // Upward drag on a SOUTH sheet should not dismiss it. + int dragDistance = sheet.getHeight(); + + dragSheet(x, startY, 0, -dragDistance, 5); + implementation.dispatchPointerRelease(x, startY - dragDistance); + flushSerialCalls(); + + awaitAnimations(form); + + assertSame(sheet, Sheet.getCurrentSheet(), + "Upward drag on a SOUTH sheet must not dismiss it"); + assertEquals(restingY, sheet.getY(), + "Sheet Y should not move when dragging in the wrong direction"); + } + + @FormTest + void disabledSwipeToDismissIgnoresDrag() throws Exception { + Form form = showFormWithSheet("Disabled Swipe"); + Sheet sheet = Sheet.getCurrentSheet(); + sheet.setSwipeToDismissEnabled(false); + int restingY = sheet.getY(); + + Container titleBar = getTitleBar(sheet); + int x = titleBar.getAbsoluteX() + titleBar.getWidth() / 2; + int startY = titleBar.getAbsoluteY() + titleBar.getHeight() / 2; + int dragDistance = (int) (sheet.getHeight() * 0.8); + + dragSheet(x, startY, 0, dragDistance, 5); + implementation.dispatchPointerRelease(x, startY + dragDistance); + flushSerialCalls(); + + awaitAnimations(form); + + assertSame(sheet, Sheet.getCurrentSheet(), + "Sheet must not be dismissed when swipeToDismiss is disabled"); + assertEquals(restingY, sheet.getY(), + "Sheet Y must not move when swipeToDismiss is disabled"); + } + + @FormTest + void allowCloseFalseIgnoresSwipe() throws Exception { + Form form = showFormWithSheet("Locked"); + Sheet sheet = Sheet.getCurrentSheet(); + sheet.setAllowClose(false); + int restingY = sheet.getY(); + + Container titleBar = getTitleBar(sheet); + int x = titleBar.getAbsoluteX() + titleBar.getWidth() / 2; + int startY = titleBar.getAbsoluteY() + titleBar.getHeight() / 2; + int dragDistance = (int) (sheet.getHeight() * 0.8); + + dragSheet(x, startY, 0, dragDistance, 5); + implementation.dispatchPointerRelease(x, startY + dragDistance); + flushSerialCalls(); + + awaitAnimations(form); + + assertSame(sheet, Sheet.getCurrentSheet(), + "Sheet must not be dismissed via swipe when allowClose is false"); + assertEquals(restingY, sheet.getY(), + "Sheet Y must not move when allowClose is false"); + } + + @FormTest + void dragOnContentAreaDoesNotInitiateDismiss() throws Exception { + Form form = showFormWithSheet("Content Drag"); + Sheet sheet = Sheet.getCurrentSheet(); + int restingY = sheet.getY(); + + Container content = sheet.getContentPane(); + int x = content.getAbsoluteX() + content.getWidth() / 2; + int startY = content.getAbsoluteY() + content.getHeight() / 2; + int dragDistance = sheet.getHeight(); + + dragSheet(x, startY, 0, dragDistance, 5); + implementation.dispatchPointerRelease(x, startY + dragDistance); + flushSerialCalls(); + + awaitAnimations(form); + + assertSame(sheet, Sheet.getCurrentSheet(), + "Drag starting in the content pane must not dismiss the sheet"); + assertEquals(restingY, sheet.getY(), + "Sheet Y must not move when drag starts outside the title bar"); + } + + private Form showFormWithSheet(String title) { + implementation.setBuiltinSoundsEnabled(false); + Form form = Display.getInstance().getCurrent(); + form.removeAll(); + form.setLayout(new BorderLayout()); + + Sheet sheet = new Sheet(null, title); + sheet.getContentPane().add(new Label("Content")); + sheet.show(0); + form.getAnimationManager().flush(); + flushSerialCalls(); + return form; + } + + private void dragSheet(int startX, int startY, int dx, int dy, int steps) { + implementation.dispatchPointerPress(startX, startY); + implementation.setHasDragStarted(true); + flushSerialCalls(); + for (int i = 1; i <= steps; i++) { + sleepQuietly(20); + int px = startX + (dx * i / steps); + int py = startY + (dy * i / steps); + implementation.dispatchPointerDrag(px, py); + } + // Send a final drag at the same final coordinate after a longer pause + // so the recorded velocity decays to zero — keeps the snap-back / no-op + // tests deterministic regardless of how fast the previous drags ran. + sleepQuietly(60); + implementation.dispatchPointerDrag(startX + dx, startY + dy); + flushSerialCalls(); + } + + private void sleepQuietly(int millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private void awaitAnimations(Form form) { + AnimationManager am = form.getAnimationManager(); + // Drive any in-flight animation through to completion. flush() + // discards rather than runs, so manually step updateAnimations() + // (which advances motions and triggers completion callbacks) until + // the manager reports nothing in progress or we hit the cap. + long deadline = System.currentTimeMillis() + 3000; + while (am.isAnimating() && System.currentTimeMillis() < deadline) { + am.updateAnimations(); + flushSerialCalls(); + sleepQuietly(10); + } + // Drain any pending postAnimations runnables that flushAnimation queued. + am.updateAnimations(); + flushSerialCalls(); + CountDownLatch latch = new CountDownLatch(1); + am.flushAnimation(latch::countDown); + try { + latch.await(500, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + fail("Interrupted while waiting for animations"); + } + flushSerialCalls(); + } + + private static Container getTitleBar(Sheet sheet) throws Exception { + Field f = Sheet.class.getDeclaredField("titleBar"); + f.setAccessible(true); + return (Container) f.get(sheet); + } +} diff --git a/scripts/android/screenshots/SheetSlideUpAnimationScreenshotTest.png b/scripts/android/screenshots/SheetSlideUpAnimationScreenshotTest.png new file mode 100644 index 0000000000..5de887a2ed Binary files /dev/null and b/scripts/android/screenshots/SheetSlideUpAnimationScreenshotTest.png differ 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 39e86683d2..b92a2a943c 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 @@ -116,6 +116,7 @@ private static int testTimeoutMs() { new BrowserComponentScreenshotTest(), new MediaPlaybackScreenshotTest(), new SheetScreenshotTest(), + new SheetSlideUpAnimationScreenshotTest(), new ImageViewerNavigationScreenshotTest(), new TabsScreenshotTest(), new TextAreaAlignmentScreenshotTest(), @@ -275,7 +276,8 @@ private static boolean isJsSkippedAnimationTest(String testName) { || "ComponentReplaceFadeScreenshotTest".equals(testName) || "ComponentReplaceSlideScreenshotTest".equals(testName) || "ComponentReplaceFlipScreenshotTest".equals(testName) - || "MotionShowcaseScreenshotTest".equals(testName); + || "MotionShowcaseScreenshotTest".equals(testName) + || "SheetSlideUpAnimationScreenshotTest".equals(testName); } private static boolean isJsSkippedScreenshotTest(String testName) { diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SheetSlideUpAnimationScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SheetSlideUpAnimationScreenshotTest.java new file mode 100644 index 0000000000..c926804eec --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SheetSlideUpAnimationScreenshotTest.java @@ -0,0 +1,81 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Graphics; +import com.codename1.ui.Label; +import com.codename1.ui.Painter; +import com.codename1.ui.Sheet; +import com.codename1.ui.animations.ComponentAnimation; +import com.codename1.ui.geom.Rectangle; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.plaf.Style; + +/// Animation grid for the sheet slide-up open animation. The host container +/// uses the same `BorderLayout.CENTER_BEHAVIOR_CENTER_ABSOLUTE` parent and +/// `BorderLayout.SOUTH` placement that `Sheet.show()` uses internally, then +/// stamps the sheet to its hidden off-screen Y before kicking off +/// `createAnimateLayout` so each captured frame walks it back to the resting +/// position. +public class SheetSlideUpAnimationScreenshotTest extends AbstractContainerAnimationScreenshotTest { + private Sheet sheet; + + @Override + protected Container buildContainer(int frameWidth, int frameHeight) { + Container parent = new Container(new BorderLayout(BorderLayout.CENTER_BEHAVIOR_CENTER_ABSOLUTE)); + Style ps = parent.getAllStyles(); + ps.setBgColor(0xf0f4f8); + ps.setBgTransparency(255); + // 30% black scrim mirrors Sheet.ShowPainter so the animation reads as + // an actual sheet over a dimmed surface, not a free-floating panel. + parent.getStyle().setBgPainter(new DimScrimPainter()); + + sheet = new Sheet(null, "Sheet Animation"); + Label icon = new Label(); + FontImage.setMaterialIcon(icon, FontImage.MATERIAL_INFO_OUTLINE, 3f); + sheet.setTitleComponent(BoxLayout.encloseYCenter(icon, new Label("Sheet Animation"))); + Container content = sheet.getContentPane(); + content.setLayout(BoxLayout.y()); + content.add(new Label("Slide-up open animation")); + content.add(new Button("Primary action")); + content.add(new Label("Secondary detail")); + parent.add(BorderLayout.SOUTH, sheet); + return parent; + } + + @Override + protected ComponentAnimation startAnimation(Container container, int duration) { + // The host has just been laid out, so the sheet sits at its resting + // Y at the bottom of the container. Reposition it just past the + // bottom edge so createAnimateLayout produces the slide-up motion - + // animateLayout records the current (hidden) Y, calls layoutContainer + // to compute the resting Y, and animates from one to the other. + sheet.setY(container.getHeight()); + return container.createAnimateLayout(duration); + } + + @Override + protected String getHostTitle() { + return "Sheet Slide Up"; + } + + @Override + protected int getAnimationDurationMillis() { + return 300; + } + + private static final class DimScrimPainter implements Painter { + @Override + public void paint(Graphics g, Rectangle rect) { + int alpha = g.getAlpha(); + g.setColor(0xf0f4f8); + g.fillRect(rect.getX(), rect.getY(), rect.getWidth(), rect.getHeight()); + g.setAlpha((int) (alpha * 0.3f)); + g.setColor(0x000000); + g.fillRect(rect.getX(), rect.getY(), rect.getWidth(), rect.getHeight()); + g.setAlpha(alpha); + } + } +} diff --git a/scripts/ios/screenshots/SheetSlideUpAnimationScreenshotTest.png b/scripts/ios/screenshots/SheetSlideUpAnimationScreenshotTest.png new file mode 100644 index 0000000000..fb0d77384b Binary files /dev/null and b/scripts/ios/screenshots/SheetSlideUpAnimationScreenshotTest.png differ diff --git a/scripts/run-android-instrumentation-tests.sh b/scripts/run-android-instrumentation-tests.sh index 314c16f84a..1b3c02ec78 100755 --- a/scripts/run-android-instrumentation-tests.sh +++ b/scripts/run-android-instrumentation-tests.sh @@ -280,6 +280,118 @@ for test in "${TEST_NAMES[@]}"; do fi done +# ---- Retry decode-only failures via adb relaunch ------------------------- +# Logcat occasionally drops a single chunk line on the JDK 8 Android image +# (typically once per run, on a different test each time). When that happens +# the test's PNG can't be reassembled even though the CN1SS_SUITE actually +# completed successfully on-device. Rather than fail the build for what is a +# transport-layer flake, restart the already-installed APK via `adb shell am +# start` (no Gradle, no recompile, no reinstall) and re-decode just the +# tests that previously failed. The on-device suite re-emits every test; +# we only redo the work for the ones that need recovery. +if [ "${#FAILED_TESTS[@]}" -gt 0 ] && [ -n "${PACKAGE_NAME:-}" ]; then + ra_log "STAGE:RETRY -> Attempting decode recovery for: ${FAILED_TESTS[*]}" + + # Resolve the package's launcher activity component so `am start` can + # invoke it directly. Falls back to the package name's main activity + # convention if cmd-package fails. + LAUNCH_COMPONENT="" + if RESOLVE_OUT="$("$ADB_BIN" shell cmd package resolve-activity --brief "$PACKAGE_NAME" 2>/dev/null | tr -d '\r' | tail -n1)"; then + LAUNCH_COMPONENT="${RESOLVE_OUT//[$'\t']/}" + fi + case "$LAUNCH_COMPONENT" in + "$PACKAGE_NAME"/*) ;; + *) LAUNCH_COMPONENT="" ;; + esac + + # Stop the original logcat tail so the new capture starts cleanly. The + # cleanup trap watches LOGCAT_PID, so reassign it to the retry tail + # below and the trap will still kill the right process on exit. + if [ "${LOGCAT_PID:-0}" -ne 0 ]; then + kill "$LOGCAT_PID" >/dev/null 2>&1 || true + wait "$LOGCAT_PID" 2>/dev/null || true + LOGCAT_PID=0 + fi + "$ADB_BIN" shell am force-stop "$PACKAGE_NAME" >/dev/null 2>&1 || true + sleep 1 + "$ADB_BIN" logcat -c >/dev/null 2>&1 || true + + RETRY_LOG="$ARTIFACTS_DIR/connectedAndroidTest-retry.log" + ra_log "Capturing retry logcat to $RETRY_LOG" + "$ADB_BIN" logcat -v threadtime > "$RETRY_LOG" 2>&1 & + RETRY_LOGCAT_PID=$! + LOGCAT_PID="$RETRY_LOGCAT_PID" + sleep 2 + + if [ -n "$LAUNCH_COMPONENT" ]; then + ra_log "Relaunching $LAUNCH_COMPONENT via adb am start" + "$ADB_BIN" shell am start -n "$LAUNCH_COMPONENT" >/dev/null 2>&1 || true + else + ra_log "Relaunching package $PACKAGE_NAME via monkey (launcher component unresolved)" + "$ADB_BIN" shell monkey -p "$PACKAGE_NAME" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true + fi + + RETRY_TIMEOUT=600 + RETRY_START="$(date +%s)" + ra_log "Waiting up to ${RETRY_TIMEOUT}s for retry — will short-circuit as soon as every previously-failed test re-emits its CN1SS:END marker" + while true; do + have_all_ends=1 + for failed_test in "${FAILED_TESTS[@]}"; do + if ! grep -q "CN1SS:END:${failed_test}" "$RETRY_LOG" 2>/dev/null; then + have_all_ends=0 + break + fi + done + if [ "$have_all_ends" = "1" ]; then + ra_log "All previously-failed tests re-emitted CN1SS:END on retry; stopping early" + break + fi + if grep -q "$END_MARKER" "$RETRY_LOG" 2>/dev/null; then + ra_log "Detected DeviceRunner completion marker on retry" + break + fi + NOW="$(date +%s)" + if [ $(( NOW - RETRY_START )) -ge $RETRY_TIMEOUT ]; then + ra_log "STAGE:RETRY_TIMEOUT -> retry did not emit completion marker within ${RETRY_TIMEOUT}s" + break + fi + sleep 3 + done + + sleep 3 + if [ "${RETRY_LOGCAT_PID:-0}" -ne 0 ]; then + kill "$RETRY_LOGCAT_PID" >/dev/null 2>&1 || true + wait "$RETRY_LOGCAT_PID" 2>/dev/null || true + RETRY_LOGCAT_PID=0 + fi + + declare -a STILL_FAILED=() + for test in "${FAILED_TESTS[@]}"; do + dest="$SCREENSHOT_TMP_DIR/${test}.png" + if source_label="$(cn1ss_decode_test_png "$test" "$dest" "LOGCAT:$RETRY_LOG")"; then + TEST_OUTPUTS["$test"]="$dest" + TEST_SOURCES["$test"]="$source_label" + ra_log "Retry decoded screenshot for '$test' (source=${source_label}, size: $(cn1ss_file_size "$dest") bytes)" + preview_dest="$SCREENSHOT_PREVIEW_DIR/${test}.jpg" + if preview_source="$(cn1ss_decode_test_preview "$test" "$preview_dest" "LOGCAT:$RETRY_LOG")"; then + PREVIEW_OUTPUTS["$test"]="$preview_dest" + ra_log "Retry decoded preview for '$test' (size: $(cn1ss_file_size "$preview_dest") bytes)" + else + rm -f "$preview_dest" 2>/dev/null || true + fi + else + ra_log "Retry still failed to decode '$test'" + STILL_FAILED+=("$test") + fi + done + FAILED_TESTS=("${STILL_FAILED[@]}") + if [ "${#FAILED_TESTS[@]}" -eq 0 ]; then + ra_log "All decode-only failures recovered on retry" + else + ra_log "Tests still failing after retry: ${FAILED_TESTS[*]}" + fi +fi + # ---- Compare against stored references ------------------------------------ COMPARE_ENTRIES=()