From a61a0239017c8e4518c2ea5c6c244176a86631d7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:18:50 +0200 Subject: [PATCH 1/5] iOS: re-read the view size when a covering modal is dismissed (#6080) Presenting the portrait-only camera over a landscape app sends the covered controller a portrait viewWillTransitionToSize:, but its bounds never change, so staying in landscape leaves no layout pass to correct the published size. viewDidAppear and the transition completion now re-sync from the bounds. Co-Authored-By: Claude Opus 5.5 --- .../CodenameOne_GLViewController.m | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m index d1ad2439361..9a80c0aa824 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m @@ -3371,6 +3371,7 @@ - (void)viewDidLayoutSubviews { } #elif !TARGET_OS_OSX && !TARGET_OS_WATCH +#define CN1_PUBLISH_VIEW_SIZE_FROM_LAYOUT 1 // Defined further down, beside the drawing code that reads it. extern bool lockDrawing; @@ -3391,6 +3392,18 @@ - (void)viewDidLayoutSubviews { // size, so it does not reach this either. - (void)viewDidLayoutSubviews { [super viewDidLayoutSubviews]; + [self cn1PublishViewSizeIfChanged]; +} + +// The other half of #6080. With the app in landscape, presenting the +// portrait-only camera sends this (covered) controller a +// viewWillTransitionToSize: to portrait, which publishes the portrait size. +// Its view is not in the window while the modal is up, so it is never laid out +// portrait; when the user stays in landscape and the modal is dismissed, the +// bounds are what they were before the camera opened, nothing changed, and no +// layout pass runs to correct the published size. viewDidAppear and the +// transition's completion therefore re-read the real bounds as well. +- (void)cn1PublishViewSizeIfChanged { // The same reason viewWillTransitionToSize: gives: iOS lays the view out // at the opposite orientation for its background snapshot. if (isAppSuspended || self.view == nil) { @@ -3681,6 +3694,9 @@ - (void)viewDidAppear:(BOOL)animated { screenSizeChanged(displayWidth, displayHeight); } } +#endif +#ifdef CN1_PUBLISH_VIEW_SIZE_FROM_LAYOUT + [self cn1PublishViewSizeIfChanged]; #endif [self becomeFirstResponder]; [self updateCanvas:animated]; @@ -4956,6 +4972,15 @@ -(void) viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id)coor screenSizeChanged(displayWidth, displayHeight); repaintUI(); +#ifdef CN1_PUBLISH_VIEW_SIZE_FROM_LAYOUT + // A size announced to a covered controller may never become its bounds + // (#6080, see cn1PublishViewSizeIfChanged); settle on the real ones. + if (coordinator != nil) { + [coordinator animateAlongsideTransition:nil completion:^(id context) { + [self cn1PublishViewSizeIfChanged]; + }]; + } +#endif if ( currentActionSheet != nil ){ [CN1View beginAnimations:nil context:nil]; From f87e6ef1fd93e956e95a9630ba7eef9f747bfce8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:48:06 +0200 Subject: [PATCH 2/5] iOS: skip the transition re-sync while covered; test the landscape-stays-landscape case (#6080) Co-Authored-By: Claude Opus 5.5 --- .../CodenameOne_GLViewController.m | 6 +- .../tests/NativeModalRotationTest.java | 57 ++++++++++++++++++- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m index 9a80c0aa824..3e0ef6f434d 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m @@ -4977,7 +4977,11 @@ -(void) viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id)coor // (#6080, see cn1PublishViewSizeIfChanged); settle on the real ones. if (coordinator != nil) { [coordinator animateAlongsideTransition:nil completion:^(id context) { - [self cn1PublishViewSizeIfChanged]; + // Still covered, the bounds are the stale ones; republishing them + // would only churn the framebuffer. viewDidAppear settles it. + if (self.presentedViewController == nil) { + [self cn1PublishViewSizeIfChanged]; + } }]; } #endif diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/NativeModalRotationTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/NativeModalRotationTest.java index 9b015c58000..5b81b16b5cc 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/NativeModalRotationTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/NativeModalRotationTest.java @@ -42,6 +42,10 @@ /// size the framework uses equals the view's size in pixels. iOS only; other /// platforms have no such modal and finish at once. /// +/// A second case starts in landscape and stays there: presenting the +/// portrait-only modal announces a portrait size to the covered view, whose +/// bounds never change, so nothing corrected it after the dismissal. +/// /// No screenshot -- this is an assertion test. public class NativeModalRotationTest extends BaseTest { private static final int POLL_INTERVAL_MS = 100; @@ -53,6 +57,7 @@ public class NativeModalRotationTest extends BaseTest { private static final int TOLERANCE_PX = 2; private NativeModalRotationNative modal; + private boolean landscapeCase; @Override public boolean shouldTakeScreenshot() { @@ -135,11 +140,17 @@ private void awaitDisplayMatchesView(final Form form, final int attemptsLeft) { int displayW = Display.getInstance().getDisplayWidth(); int displayH = Display.getInstance().getDisplayHeight(); if (Math.abs(viewW - displayW) <= TOLERANCE_PX && Math.abs(viewH - displayH) <= TOLERANCE_PX) { - finish(form, null); + if (landscapeCase) { + finish(form, null); + } else { + landscapeCase = true; + startLandscape(form); + } return; } if (attemptsLeft <= 0) { - finish(form, "after a native modal closed over a rotation the view is " + finish(form, (landscapeCase ? "landscape case: " : "") + + "after a native modal closed over a rotation the view is " + viewW + "x" + viewH + " pixels but the display is " + displayW + "x" + displayH + " (issue #6080)"); return; @@ -151,6 +162,48 @@ public void run() { }); } + /// Landscape before the modal, landscape under it, landscape after it. + private void startLandscape(final Form form) { + CN.lockOrientation(false); + poll(form, POLL_ATTEMPTS, new Condition() { + public boolean holds() { + return !modal.isModalPresented() + && modal.getViewPixelWidth() > modal.getViewPixelHeight(); + } + }, "the device to start the landscape case in landscape", new Runnable() { + public void run() { + modal.presentPortraitOnlyModal(); + poll(form, POLL_ATTEMPTS, new Condition() { + public boolean holds() { + return modal.isModalPresented(); + } + }, "the native modal to be presented over landscape", new Runnable() { + public void run() { + UITimer.timer(UNDER_MODAL_SETTLE_MS, false, form, new Runnable() { + public void run() { + System.out.println("CN1SS:INFO:test=NativeModalRotationTest landscape case under the modal view=" + + modal.getViewPixelWidth() + "x" + modal.getViewPixelHeight() + + " display=" + Display.getInstance().getDisplayWidth() + + "x" + Display.getInstance().getDisplayHeight()); + modal.dismissModal(); + poll(form, POLL_ATTEMPTS, new Condition() { + public boolean holds() { + return !modal.isModalPresented() + && modal.getViewPixelWidth() > modal.getViewPixelHeight(); + } + }, "the modal to close with the view still landscape", new Runnable() { + public void run() { + awaitDisplayMatchesView(form, POLL_ATTEMPTS); + } + }); + } + }); + } + }); + } + }); + } + private void finish(final Form form, final String failure) { modal.dismissModal(); LandscapeCapture.restorePortrait(form, new Runnable() { From c95701c1a77847aa88ce059b471a92c31a36825e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:48:54 +0200 Subject: [PATCH 3/5] test: require the landscape case's precondition (#6080) Co-Authored-By: Claude Opus 5.5 --- .../tests/NativeModalRotationTest.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/NativeModalRotationTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/NativeModalRotationTest.java index 5b81b16b5cc..728a3c74d7c 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/NativeModalRotationTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/NativeModalRotationTest.java @@ -185,6 +185,18 @@ public void run() { + modal.getViewPixelWidth() + "x" + modal.getViewPixelHeight() + " display=" + Display.getInstance().getDisplayWidth() + "x" + Display.getInstance().getDisplayHeight()); + // This state is the bug's precondition: the covered view + // was told portrait while its bounds stayed landscape. + // Without it the dismissal below proves nothing. + if (Display.getInstance().getDisplayWidth() >= Display.getInstance().getDisplayHeight() + || modal.getViewPixelWidth() <= modal.getViewPixelHeight()) { + finish(form, "landscape case precondition: the modal did not announce portrait" + + " to the covered landscape view; view=" + + modal.getViewPixelWidth() + "x" + modal.getViewPixelHeight() + + " display=" + Display.getInstance().getDisplayWidth() + + "x" + Display.getInstance().getDisplayHeight()); + return; + } modal.dismissModal(); poll(form, POLL_ATTEMPTS, new Condition() { public boolean holds() { From 2dfdff6ce6ab7cfc94362bd89d859568bd15da0f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:08:22 +0200 Subject: [PATCH 4/5] test: present over an unlocked landscape app, as the reporter does (#6080) Under a landscape lock UIKit keeps the interface landscape with the portrait-only modal up, so the covered view is never told portrait. Co-Authored-By: Claude Opus 5.5 --- .../tests/NativeModalRotationTest.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/NativeModalRotationTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/NativeModalRotationTest.java index 728a3c74d7c..f1306be4cc3 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/NativeModalRotationTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/NativeModalRotationTest.java @@ -171,6 +171,27 @@ public boolean holds() { && modal.getViewPixelWidth() > modal.getViewPixelHeight(); } }, "the device to start the landscape case in landscape", new Runnable() { + public void run() { + // Unlocked, as the reporter's app is: under a landscape lock + // UIKit keeps the interface landscape even with the portrait-only + // modal up, so the covered view is never told portrait and the + // bug cannot happen. The device itself stays landscape. + CN.unlockOrientation(); + UITimer.timer(UNDER_MODAL_SETTLE_MS, false, form, new Runnable() { + public void run() { + presentOverLandscape(form); + } + }); + } + }); + } + + private void presentOverLandscape(final Form form) { + poll(form, POLL_ATTEMPTS, new Condition() { + public boolean holds() { + return modal.getViewPixelWidth() > modal.getViewPixelHeight(); + } + }, "the unlocked view to stay landscape", new Runnable() { public void run() { modal.presentPortraitOnlyModal(); poll(form, POLL_ATTEMPTS, new Condition() { From 74d7a6645e9910739d474786aa8a205057343cee Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:00:36 +0200 Subject: [PATCH 5/5] iOS: re-read the view size after updateCanvas when a modal closes (#6080) The simulator cannot stage case 3 (the stand-in modal never makes the covered view disagree with the scene), so the landscape test case is dropped and the correction is logged instead, to name the stale publication on a device. Co-Authored-By: Claude Opus 5.5 --- .../CodenameOne_GLViewController.m | 42 ++++++--- .../tests/NativeModalRotationTest.java | 90 +------------------ 2 files changed, 31 insertions(+), 101 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m index 3e0ef6f434d..7e22e11a7b8 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m @@ -3395,14 +3395,13 @@ - (void)viewDidLayoutSubviews { [self cn1PublishViewSizeIfChanged]; } -// The other half of #6080. With the app in landscape, presenting the -// portrait-only camera sends this (covered) controller a -// viewWillTransitionToSize: to portrait, which publishes the portrait size. -// Its view is not in the window while the modal is up, so it is never laid out -// portrait; when the user stays in landscape and the modal is dismissed, the -// bounds are what they were before the camera opened, nothing changed, and no -// layout pass runs to correct the published size. viewDidAppear and the -// transition's completion therefore re-read the real bounds as well. +// The other half of #6080. With the app in landscape, a stale size can be +// published around the portrait-only camera covering this view -- possibly a +// portrait viewWillTransitionToSize:, or updateCanvas: swapping the bounds to +// match a scene that still reports portrait; the device log did not say. The view itself +// never leaves landscape, so when the user stays in landscape its bounds never +// change and no layout pass runs to correct the published size. viewDidAppear +// and the transition's completion therefore re-read the real bounds as well. - (void)cn1PublishViewSizeIfChanged { // The same reason viewWillTransitionToSize: gives: iOS lays the view out // at the opposite orientation for its background snapshot. @@ -3422,6 +3421,10 @@ - (void)cn1PublishViewSizeIfChanged { if (dw <= 1 && dh <= 1) { return; } + // Rare by construction (a rotation normally publishes first), so it is + // worth a line: it names the stale size the next time #6080 recurs. + CN1Log(@"CN1: view is %dx%d px but the published size was %dx%d; republishing", + newW, newH, displayWidth, displayHeight); [[self renderingView] updateFrameBufferSize:newW h:newH]; [[self renderingView] deleteFramebuffer]; displayWidth = newW; @@ -3694,12 +3697,22 @@ - (void)viewDidAppear:(BOOL)animated { screenSizeChanged(displayWidth, displayHeight); } } -#endif -#ifdef CN1_PUBLISH_VIEW_SIZE_FROM_LAYOUT - [self cn1PublishViewSizeIfChanged]; #endif [self becomeFirstResponder]; [self updateCanvas:animated]; +#ifdef CN1_PUBLISH_VIEW_SIZE_FROM_LAYOUT + // After the portrait-only camera closes over a landscape app that stayed + // landscape, the reported display was portrait while the view's bounds + // were landscape (#6080 case 3), and since the bounds never change no + // layout pass corrects it. Which publication went stale is not pinned + // down: updateCanvas: itself sizes through cn1OrientationCorrectSize, + // which swaps the bounds if the scene still reports portrait at this + // moment. So the bounds are re-read once the dismissal has settled, after + // updateCanvas:, and cn1PublishViewSizeIfChanged logs any correction. + dispatch_async(dispatch_get_main_queue(), ^{ + [self cn1PublishViewSizeIfChanged]; + }); +#endif // Re-install / bring the status-bar tap proxy to the front. Native peers // (browsers, video, etc.) added after viewDidLoad can obscure it or push // sibling scroll views into the hierarchy. @@ -4965,8 +4978,11 @@ -(void) viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id)coor [[self renderingView] updateFrameBufferSize:(int)(size.width * scaleValue) h:(int)(size.height * scaleValue)]; [[self renderingView] deleteFramebuffer]; - displayWidth = (int)size.width * scaleValue; - displayHeight = (int)size.height * scaleValue; + // Scale before truncating, as cn1PublishViewSizeIfChanged does: a + // fractional point width would otherwise disagree with it by more than + // its tolerance and republish after every resize. + displayWidth = (int)(size.width * scaleValue); + displayHeight = (int)(size.height * scaleValue); lockDrawing = NO; diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/NativeModalRotationTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/NativeModalRotationTest.java index f1306be4cc3..9b015c58000 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/NativeModalRotationTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/NativeModalRotationTest.java @@ -42,10 +42,6 @@ /// size the framework uses equals the view's size in pixels. iOS only; other /// platforms have no such modal and finish at once. /// -/// A second case starts in landscape and stays there: presenting the -/// portrait-only modal announces a portrait size to the covered view, whose -/// bounds never change, so nothing corrected it after the dismissal. -/// /// No screenshot -- this is an assertion test. public class NativeModalRotationTest extends BaseTest { private static final int POLL_INTERVAL_MS = 100; @@ -57,7 +53,6 @@ public class NativeModalRotationTest extends BaseTest { private static final int TOLERANCE_PX = 2; private NativeModalRotationNative modal; - private boolean landscapeCase; @Override public boolean shouldTakeScreenshot() { @@ -140,17 +135,11 @@ private void awaitDisplayMatchesView(final Form form, final int attemptsLeft) { int displayW = Display.getInstance().getDisplayWidth(); int displayH = Display.getInstance().getDisplayHeight(); if (Math.abs(viewW - displayW) <= TOLERANCE_PX && Math.abs(viewH - displayH) <= TOLERANCE_PX) { - if (landscapeCase) { - finish(form, null); - } else { - landscapeCase = true; - startLandscape(form); - } + finish(form, null); return; } if (attemptsLeft <= 0) { - finish(form, (landscapeCase ? "landscape case: " : "") - + "after a native modal closed over a rotation the view is " + finish(form, "after a native modal closed over a rotation the view is " + viewW + "x" + viewH + " pixels but the display is " + displayW + "x" + displayH + " (issue #6080)"); return; @@ -162,81 +151,6 @@ public void run() { }); } - /// Landscape before the modal, landscape under it, landscape after it. - private void startLandscape(final Form form) { - CN.lockOrientation(false); - poll(form, POLL_ATTEMPTS, new Condition() { - public boolean holds() { - return !modal.isModalPresented() - && modal.getViewPixelWidth() > modal.getViewPixelHeight(); - } - }, "the device to start the landscape case in landscape", new Runnable() { - public void run() { - // Unlocked, as the reporter's app is: under a landscape lock - // UIKit keeps the interface landscape even with the portrait-only - // modal up, so the covered view is never told portrait and the - // bug cannot happen. The device itself stays landscape. - CN.unlockOrientation(); - UITimer.timer(UNDER_MODAL_SETTLE_MS, false, form, new Runnable() { - public void run() { - presentOverLandscape(form); - } - }); - } - }); - } - - private void presentOverLandscape(final Form form) { - poll(form, POLL_ATTEMPTS, new Condition() { - public boolean holds() { - return modal.getViewPixelWidth() > modal.getViewPixelHeight(); - } - }, "the unlocked view to stay landscape", new Runnable() { - public void run() { - modal.presentPortraitOnlyModal(); - poll(form, POLL_ATTEMPTS, new Condition() { - public boolean holds() { - return modal.isModalPresented(); - } - }, "the native modal to be presented over landscape", new Runnable() { - public void run() { - UITimer.timer(UNDER_MODAL_SETTLE_MS, false, form, new Runnable() { - public void run() { - System.out.println("CN1SS:INFO:test=NativeModalRotationTest landscape case under the modal view=" - + modal.getViewPixelWidth() + "x" + modal.getViewPixelHeight() - + " display=" + Display.getInstance().getDisplayWidth() - + "x" + Display.getInstance().getDisplayHeight()); - // This state is the bug's precondition: the covered view - // was told portrait while its bounds stayed landscape. - // Without it the dismissal below proves nothing. - if (Display.getInstance().getDisplayWidth() >= Display.getInstance().getDisplayHeight() - || modal.getViewPixelWidth() <= modal.getViewPixelHeight()) { - finish(form, "landscape case precondition: the modal did not announce portrait" - + " to the covered landscape view; view=" - + modal.getViewPixelWidth() + "x" + modal.getViewPixelHeight() - + " display=" + Display.getInstance().getDisplayWidth() - + "x" + Display.getInstance().getDisplayHeight()); - return; - } - modal.dismissModal(); - poll(form, POLL_ATTEMPTS, new Condition() { - public boolean holds() { - return !modal.isModalPresented() - && modal.getViewPixelWidth() > modal.getViewPixelHeight(); - } - }, "the modal to close with the view still landscape", new Runnable() { - public void run() { - awaitDisplayMatchesView(form, POLL_ATTEMPTS); - } - }); - } - }); - } - }); - } - }); - } - private void finish(final Form form, final String failure) { modal.dismissModal(); LandscapeCapture.restorePortrait(form, new Runnable() {