Skip to content

fix(native-view): resolve nested view content loss after rotation#110

Merged
kherembourg merged 2 commits intomainfrom
fix/nested-view-rotation-layout
May 4, 2026
Merged

fix(native-view): resolve nested view content loss after rotation#110
kherembourg merged 2 commits intomainfrom
fix/nested-view-rotation-layout

Conversation

@kherembourg
Copy link
Copy Markdown
Collaborator

@kherembourg kherembourg commented Mar 19, 2026

Summary

Fixes a bug where the nested Purchasely view (PLYPresentationView) loses all content when switching tabs (e.g. Annual ↔ Monthly) after a device orientation change. Reported by Headspace via Slack.

Bug

Steps to reproduce:

  1. Display a Purchasely paywall using nested view (PLYPresentationView)
  2. Switch between tabs (Annual/Monthly) — works fine
  3. Rotate device to landscape, then back to portrait
  4. Switch tabs again → content area is completely empty (only tabs, "Restore purchase", and CTA button remain)

Screenshots (before fix):

After rotation + tab switch Expected
Empty content, only tabs + button visible Full paywall with image, title, timeline

Root Cause

The NativeView on iOS embedded the native UIViewController's view as a bare UIView without proper view controller containment. This meant:

  1. The controller never received viewWillTransition(to:with:) callbacks during rotation
  2. The SDK's internal layout (webview/collection view) kept stale dimensions from the pre-rotation frame
  3. On tab switch post-rotation, the content view was laid out with zero/incorrect size → rendered empty

Fix

iOS (NativeView.swift)

  • Container view: Wrap the controller's view in a NativeContainerView that overrides layoutSubviews() to force child relayout on bounds changes
  • Autoresizing mask: [.flexibleWidth, .flexibleHeight] so the view follows frame changes
  • VC containment: Proper addChild/didMove(toParent:) so the controller participates in the view controller lifecycle
  • Orientation observer: Listen for UIDevice.orientationDidChangeNotification and explicitly call viewWillTransition(to:with:) with a NoAnimationTransitionCoordinator to trigger the SDK's internal relayout
  • Cleanup: Proper deinit with removeFromParent, removeObserver, and removeFromSuperview

Android (NativeView.kt)

  • Implement dispose() to call removeAllViews() (was a no-op)
  • Fix misleading log message on fallback path (Log.e "built successfully" → Log.d with correct message)

Dart (native_view_widget.dart)

  • Replace hardcoded TextDirection.ltr with Directionality.of(context) for RTL support
  • Remove debug print() statements

Test plan

  • Unit tests added for iOS (NativeViewTests — 6 tests: container view layout, autoresizing, VC containment, rotation propagation)
  • Unit tests added for Dart (PLYPresentationView layout direction — RTL/LTR context test)
  • Manual test: nested view rotation on iPhone 16e simulator — content persists after rotation + tab switch
  • Manual test: verify on physical device
  • Manual test: verify on Android

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved native view cleanup and lifecycle management for better stability.
    • Enhanced orientation change handling for proper view reflow during device rotation.
    • Better support for bidirectional layout directions (RTL/LTR).
  • Tests

    • Added comprehensive test coverage for native view lifecycle and layout behavior.

…ce rotation

The nested Purchasely view (PLYPresentationView) lost all content when
switching tabs (Annual/Monthly) after a device orientation change.

Root cause: the native UIViewController was embedded as a bare UIView
without proper view controller containment. This meant it never received
`viewWillTransition(to:with:)` callbacks, so the SDK's internal layout
(webview/collection view) kept stale dimensions and rendered empty content
on tab switch.

Fix (iOS):
- Wrap the controller's view in a NativeContainerView that forces child
  relayout on bounds changes
- Set autoresizingMask for flexible width/height
- Add proper VC containment (addChild/didMove) so the controller receives
  lifecycle events
- Observe UIDevice.orientationDidChangeNotification and explicitly call
  viewWillTransition(to:with:) with a NoAnimationTransitionCoordinator
- Add proper cleanup in deinit (removeFromParent, removeObserver)

Fix (Android):
- Implement dispose() to call removeAllViews() instead of no-op
- Fix misleading log message on fallback path

Fix (Dart):
- Replace hardcoded TextDirection.ltr with Directionality.of(context) for
  RTL support
- Remove debug print statements

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 19, 2026

📝 Walkthrough

Walkthrough

Multiple platform layers (iOS, Android, Flutter) updated to improve native view container lifecycle management and orientation handling. iOS now properly manages UIViewController containment with automatic frame synchronization and orientation observers. Android disposal improved. Flutter layout direction now respects app directionality context instead of hardcoding left-to-right.

Changes

Cohort / File(s) Summary
iOS Native Container Architecture
purchasely/ios/Classes/NativeView.swift
Restructured view hierarchy with new NativeContainerView for proper frame synchronization. Added UIViewController lifecycle management via addChild/removeFromParent. Implemented orientation change detection with notification observer that triggers frame updates and layout propagation. Enhanced .presentationClosed handler for complete controller detachment.
iOS Test Coverage
purchasely/example/ios/RunnerTests/SwiftPurchaselyFlutterPluginTests.swift
Added comprehensive NativeViewTests suite with five tests validating container-view subview placement, autoresizing mask behavior, frame adaptation on orientation changes, parent/child controller relationships, and notification name equality.
Android View Disposal
purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/NativeView.kt
Updated dispose() to actively clear view hierarchy via removeAllViews(). Changed fallback logging from error to debug level. Added end-of-file newline.
Flutter Layout Direction
purchasely/lib/native_view_widget.dart
Removed debug print statements. Changed Android layoutDirection from hardcoded TextDirection.ltr to dynamic Directionality.of(context) for RTL support.
Flutter Layout Direction Test
purchasely/test/native_view_widget_test.dart
Added test validating PLYPresentationView rendering stability across layout direction changes using Directionality wrapper on Android platform.

Sequence Diagram(s)

sequenceDiagram
    actor System as System Orientation Change
    participant Flutter as Flutter Platform View
    participant Native as NativeView Controller
    participant Container as NativeContainerView
    participant Child as UIViewController (Child)
    
    System->>Native: UIDevice.orientationDidChangeNotification
    Native->>Native: Delay & execute layout update
    Native->>Child: Update controller.view.frame to bounds
    Native->>Child: Call viewWillTransition(to:with:) with coordinator
    Native->>Container: setNeedsLayout()
    Native->>Container: layoutIfNeeded()
    Container->>Container: layoutSubviews() - sync child frames
    Container->>Child: Recursively force layout on subviews
    Child-->>Native: Layout updated
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes


🐰✨ New views that shine so bright,
Container frames now hold them tight,
Orientation dances left and right,
Lifecycle management—what a delight!
The layout flows like morning light. 🌅

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main bug fix: resolving nested view content loss after device rotation, which is the central issue addressed across iOS, Android, and Dart changes.
Description check ✅ Passed The description is comprehensive, including clear bug reproduction steps, root cause analysis, detailed fix explanations for each platform, test coverage, and verification status; all required template sections are addressed.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/nested-view-rotation-layout
📝 Coding Plan
  • Generate coding plan for human review comments

Comment @coderabbitai help to get the list of available commands and usage tips.

@kherembourg kherembourg requested review from El-Fitz and Copilot March 19, 2026 14:39
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Mar 19, 2026

Greptile Summary

This PR fixes a rotation-triggered content-loss bug in PLYPresentationView on iOS by introducing proper VC containment (addChild/didMove(toParent:)), a NativeContainerView that forces child relayout on bounds changes, and a UIDevice.orientationDidChangeNotification observer that manually calls viewWillTransition(to:with:). Android gets a small cleanup (dispose() now calls removeAllViews(), log-level fix) and Dart drops debug prints while adding RTL support via Directionality.maybeOf(context).

  • The RTL fix in native_view_widget.dart is applied only to the AndroidView branch; the UiKitView (iOS) branch is missing the same layoutDirection parameter, leaving iOS RTL support incomplete.
  • In orientationDidChange(), controller is captured as a strong local in the asyncAfter closure, so it can fire after cleanupController() has already detached the controller, calling viewWillTransition on a parentless VC.

Confidence Score: 5/5

Safe to merge; all findings are P2 quality/robustness suggestions with no production-blocking bugs.

No P0/P1 issues found. The iOS rotation fix is well-structured and the previous round of review feedback has been fully addressed. Remaining P2s are about defensive coding (stale controller in async block, stacked orientation closures) and an incomplete RTL fix on iOS.

purchasely/ios/Classes/NativeView.swift (async relayout stacking) and purchasely/lib/native_view_widget.dart (missing UiKitView layoutDirection)

Important Files Changed

Filename Overview
purchasely/ios/Classes/NativeView.swift Major rewrite: adds NativeContainerView, proper VC containment via findRootViewController(), orientation observer with async relayout, and NoAnimationTransitionCoordinator — addresses the core rotation/content-loss bug; two P2 issues around stacked async closures and stale controller references after cleanup.
purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/NativeView.kt Small, clean fixes: dispose() now calls removeAllViews(), redundant ViewGroup cast removed in closeCallback(), misleading Log.e corrected to Log.d, and trailing newline added.
purchasely/lib/native_view_widget.dart Debug prints removed and Directionality.maybeOf() applied for Android RTL support; iOS UiKitView branch is missing the same layoutDirection fix, leaving RTL incomplete on iOS.
purchasely/example/ios/RunnerTests/SwiftPurchaselyFlutterPluginTests.swift Adds 6 NativeView tests, but all test plain UIKit/UIViewController primitives rather than NativeView itself (SDK dependency prevents direct instantiation).
purchasely/test/native_view_widget_test.dart Adds Android RTL/LTR layout direction tests; correctly verifies Directionality inheritance and fallback, but no corresponding iOS UiKitView tests.

Sequence Diagram

sequenceDiagram
    participant D as UIDevice
    participant NV as NativeView
    participant CQ as DispatchQueue.main
    participant CV as NativeContainerView
    participant VC as PLY UIViewController

    Note over NV: init()
    NV->>CV: NativeContainerView(frame:)
    NV->>VC: controller.view addSubview(childView)
    NV->>VC: rootVC.addChild(controller)
    NV->>D: beginGeneratingDeviceOrientationNotifications()
    NV->>NV: addObserver(orientationDidChange)

    Note over D: Device rotates
    D-->>NV: orientationDidChangeNotification
    NV->>CQ: asyncAfter(+0.1s)
    CQ->>CV: read bounds.size (newSize)
    CQ->>VC: controller.view.frame = containerView.bounds
    CQ->>VC: viewWillTransition(to: newSize, with: coordinator)
    CQ->>VC: setNeedsLayout() / layoutIfNeeded()
    CQ->>NV: forceLayoutRecursive(controller.view)

    Note over NV: PLYEvent.presentationClosed
    NV->>VC: willMove(toParent: nil)
    NV->>VC: removeFromParent()
    NV->>VC: view.removeFromSuperview()

    Note over NV: deinit
    NV->>NV: removeObserver(self)
    NV->>D: endGeneratingDeviceOrientationNotifications()
    NV->>VC: cleanupController()
Loading

Fix All in Claude Code Fix All in Cursor Fix All in Codex

Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
purchasely/lib/native_view_widget.dart:55-60
**`UiKitView` missing `layoutDirection` — RTL fix is Android-only**

The PR's stated goal includes RTL support via `Directionality.maybeOf(context)`, but the iOS branch's `UiKitView` is left without the same fix. `UiKitView` accepts an identical `layoutDirection` parameter, so omitting it means iOS will silently default to `TextDirection.ltr` regardless of the app's inherited directionality. The new tests only exercise the Android path.

```suggestion
        return SafeArea(
          // Wrap UiKitView with SafeArea
          child: UiKitView(
            viewType: viewType,
            layoutDirection: Directionality.maybeOf(context) ?? TextDirection.ltr,
            creationParams: creationParams,
```

### Issue 2 of 3
purchasely/ios/Classes/NativeView.swift:47-50
**Stale `controller` reference survives `cleanupController()` in async block**

In `orientationDidChange()`, `controller` is captured as a strong local variable by the `asyncAfter` closure. If `cleanupController()` fires (via `presentationClosed` event or `deinit`) within the 100 ms window, the closure will still execute — calling `controller.view.frame = ...` and `viewWillTransition(to:with:)` on an already-detached, parentless view controller. While unlikely to crash, this can trigger unexpected relayout callbacks inside the Purchasely SDK against a controller that has been removed from the hierarchy. Guard against the cleaned-up state inside the async block by re-reading `self._controller` inside the closure instead of capturing the local.

### Issue 3 of 3
purchasely/ios/Classes/NativeView.swift:44-57
**Rapid rotation stacks multiple async relayout closures**

Every `orientationDidChange` notification enqueues a new `asyncAfter(deadline: .now() + 0.1)` block without cancelling any prior pending block. During a quick double-rotation the first closure fires against stale `_containerView.bounds` (from the intermediate orientation), resizes the content view to the wrong size, and 100 ms later the second closure corrects it — producing visible flicker. Consider using a `DispatchWorkItem` stored as a property so each new notification cancels the previous pending relayout before scheduling a fresh one.

Reviews (2): Last reviewed commit: "fix(native-view): address review comment..." | Re-trigger Greptile

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (6)
purchasely/test/native_view_widget_test.dart (1)

203-235: Consider adding explicit expectations.

The test verifies no crash occurs but doesn't include explicit assertions. Adding at least a find expectation would make test intent clearer and catch potential regressions in widget structure.

♻️ Suggestion to add explicit expectations
       // RTL context
       await tester.pumpWidget(
         MaterialApp(
           home: Directionality(
             textDirection: TextDirection.rtl,
             child: Scaffold(body: view),
           ),
         ),
       );
+
+      // Verify AndroidView is rendered
+      expect(find.byType(AndroidView), findsOneWidget);

       // No crash = layout direction is resolved from context
       debugDefaultTargetPlatformOverride = null;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@purchasely/test/native_view_widget_test.dart` around lines 203 - 235, The
test currently only ensures no crash when switching layout directions; add
explicit assertions after each pump to verify the widget is present and layout
direction applied: after pumping the LTR MaterialApp and RTL MaterialApp, call
expectations such as expect(find.byType(PLYPresentationView), findsOneWidget)
and a directional-dependent check (e.g., expect(find.byWidgetPredicate(w => /*
check Directionality or a child layout property tied to TextDirection */),
findsOneWidget)) so the test asserts the view is rendered and responds to
context; reference PLYPresentationView, testWidgets, tester.pumpWidget and
debugDefaultTargetPlatformOverride to locate where to insert the expectations.
purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/NativeView.kt (1)

27-29: Redundant cast to ViewGroup.

FrameLayout already extends ViewGroup, so the cast is unnecessary. The same applies to the existing closeCallback() method.

♻️ Optional simplification
 override fun dispose() {
-    (layout as ViewGroup).removeAllViews()
+    layout.removeAllViews()
 }

And optionally in closeCallback():

 private fun closeCallback() {
-    (layout as ViewGroup).removeAllViews()
+    layout.removeAllViews()
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/NativeView.kt`
around lines 27 - 29, The cast to ViewGroup is redundant because layout is a
FrameLayout (which already extends ViewGroup); update the dispose() method to
call removeAllViews() directly on layout (e.g., layout.removeAllViews()) and
likewise remove the unnecessary cast in closeCallback() where layout is cast to
ViewGroup; reference the dispose(), closeCallback(), and layout symbols when
making these edits.
purchasely/ios/Classes/NativeView.swift (4)

44-57: Hard-coded delay is fragile for layout coordination.

The 0.1-second delay at line 47 assumes Flutter will have completed its resize within that time. On slower devices or under load, this may not hold true, potentially causing layout glitches.

Consider a more robust approach such as observing bounds changes on the container view via KVO or overriding layoutSubviews in NativeContainerView to trigger the controller re-layout when the frame actually changes.

Alternative using bounds observation in NativeContainerView
 private class NativeContainerView: UIView {
     weak var controller: UIViewController?
+    private var lastBounds: CGRect = .zero
 
     override func layoutSubviews() {
         super.layoutSubviews()
+        if bounds != lastBounds, let controller = controller {
+            lastBounds = bounds
+            controller.viewWillTransition(to: bounds.size, with: NoAnimationTransitionCoordinator())
+        }
         for child in subviews {
             if child.frame != bounds {

Then remove the asyncAfter delay and the notification observer, relying on layoutSubviews instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@purchasely/ios/Classes/NativeView.swift` around lines 44 - 57, The hard-coded
0.1s delay in orientationDidChange is fragile; remove the
DispatchQueue.main.asyncAfter and the orientation notification usage and instead
trigger re-layout when the container's actual bounds change by overriding
layoutSubviews in NativeContainerView (or adding a bounds observer/KVO on
_containerView). In that override/observer, compute the new size from
_containerView.bounds, set controller.view.frame = _containerView.bounds, call
controller.viewWillTransition(to:with:) using
NoAnimationTransitionCoordinator(), then call view.setNeedsLayout(),
view.layoutIfNeeded(), and forceLayoutRecursive(controller.view) so the
controller re-layouts exactly when the container actually changes.

22-33: Consider safer patterns for controller setup.

Two observations:

  1. Line 23: Force unwrapping controller.view! could crash if view loading fails. Consider using guard let childView = controller.view else { return }.

  2. Lines 30-31: Accessing UIApplication.shared.delegate?.window?? is deprecated on iOS 13+ with multi-scene support. While Flutter apps typically use a single scene, consider using the view's window scene for future-proofing:

Suggested improvement
-            if let rootVC = UIApplication.shared.delegate?.window??.rootViewController {
+            if let rootVC = _containerView.window?.rootViewController ?? UIApplication.shared.delegate?.window??.rootViewController {
                 rootVC.addChild(controller)
                 controller.didMove(toParent: rootVC)
             }

Note: Since _containerView won't have a window at init time, you may need to defer containment setup to when the view is added to the hierarchy (e.g., in didMoveToWindow).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@purchasely/ios/Classes/NativeView.swift` around lines 22 - 33, Replace the
unsafe force-unwrap and deprecated window access: instead of using
controller.view! and UIApplication.shared.delegate?.window??, guard-let unwrap
controller.view (e.g., guard let childView = _controller?.view else { return })
and add it to _containerView; defer attaching the view controller to a parent
until the view has a window (implement containment in
_containerView.didMoveToWindow or when view.window != nil) and use the view's
window/scene to find a parent view controller (e.g., view.window?.windowScene or
view.window?.rootViewController) rather than
UIApplication.shared.delegate?.window?? to avoid deprecation and crashes.

108-108: containerView creates a new instance on each access.

The computed property returns UIView() each time it's accessed. While likely harmless given the coordinator's brief lifetime, returning a stable instance would be more correct:

+    private let _containerView = UIView()
-    var containerView: UIView { UIView() }
+    var containerView: UIView { _containerView }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@purchasely/ios/Classes/NativeView.swift` at line 108, The computed property
containerView currently returns a new UIView each time (var containerView:
UIView { UIView() }); change it to a stored instance so the same UIView is
returned on every access (e.g., make containerView a private let or lazy var
initialized once in the NativeView class/struct) so the coordinator and any
other consumers reference a stable view instance instead of creating a new one
on each access.

71-76: Duplicate cleanup logic between deinit and eventTriggered.

Both deinit (lines 71-76) and the .presentationClosed handler (lines 144-148) perform the same controller detachment sequence. If the event fires before deallocation, deinit will redundantly attempt to clean up an already-detached controller.

Consider consolidating cleanup into a single method and guarding against double execution:

Proposed consolidation
+    private var isCleanedUp = false
+
+    private func cleanupController() {
+        guard !isCleanedUp, let controller = _controller else { return }
+        isCleanedUp = true
+        controller.willMove(toParent: nil)
+        controller.view.removeFromSuperview()
+        controller.removeFromParent()
+    }
+
     deinit {
         NotificationCenter.default.removeObserver(self)
-        _controller?.willMove(toParent: nil)
-        _controller?.view.removeFromSuperview()
-        _controller?.removeFromParent()
+        cleanupController()
     }

And in eventTriggered:

         if event == .presentationClosed {
             DispatchQueue.main.async { [weak self] in
-                self?._controller?.willMove(toParent: nil)
-                self?._controller?.view.removeFromSuperview()
-                self?._controller?.removeFromParent()
+                self?.cleanupController()
             }
         }

Also applies to: 144-148

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@purchasely/ios/Classes/NativeView.swift` around lines 71 - 76, Duplicate
cleanup is happening in deinit and the .presentationClosed handler; extract the
detach/remove sequence into a single private helper (e.g., cleanupController())
that checks/guards for already-cleared state (inspect _controller,
_controller?.parent, or _controller?.view.superview) before calling
willMove(toParent:), view.removeFromSuperview(), removeFromParent(), and finally
nil-ing _controller; then call cleanupController() from both deinit and the
eventTriggered/.presentationClosed handler to avoid double execution.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/NativeView.kt`:
- Around line 27-29: The cast to ViewGroup is redundant because layout is a
FrameLayout (which already extends ViewGroup); update the dispose() method to
call removeAllViews() directly on layout (e.g., layout.removeAllViews()) and
likewise remove the unnecessary cast in closeCallback() where layout is cast to
ViewGroup; reference the dispose(), closeCallback(), and layout symbols when
making these edits.

In `@purchasely/ios/Classes/NativeView.swift`:
- Around line 44-57: The hard-coded 0.1s delay in orientationDidChange is
fragile; remove the DispatchQueue.main.asyncAfter and the orientation
notification usage and instead trigger re-layout when the container's actual
bounds change by overriding layoutSubviews in NativeContainerView (or adding a
bounds observer/KVO on _containerView). In that override/observer, compute the
new size from _containerView.bounds, set controller.view.frame =
_containerView.bounds, call controller.viewWillTransition(to:with:) using
NoAnimationTransitionCoordinator(), then call view.setNeedsLayout(),
view.layoutIfNeeded(), and forceLayoutRecursive(controller.view) so the
controller re-layouts exactly when the container actually changes.
- Around line 22-33: Replace the unsafe force-unwrap and deprecated window
access: instead of using controller.view! and
UIApplication.shared.delegate?.window??, guard-let unwrap controller.view (e.g.,
guard let childView = _controller?.view else { return }) and add it to
_containerView; defer attaching the view controller to a parent until the view
has a window (implement containment in _containerView.didMoveToWindow or when
view.window != nil) and use the view's window/scene to find a parent view
controller (e.g., view.window?.windowScene or view.window?.rootViewController)
rather than UIApplication.shared.delegate?.window?? to avoid deprecation and
crashes.
- Line 108: The computed property containerView currently returns a new UIView
each time (var containerView: UIView { UIView() }); change it to a stored
instance so the same UIView is returned on every access (e.g., make
containerView a private let or lazy var initialized once in the NativeView
class/struct) so the coordinator and any other consumers reference a stable view
instance instead of creating a new one on each access.
- Around line 71-76: Duplicate cleanup is happening in deinit and the
.presentationClosed handler; extract the detach/remove sequence into a single
private helper (e.g., cleanupController()) that checks/guards for
already-cleared state (inspect _controller, _controller?.parent, or
_controller?.view.superview) before calling willMove(toParent:),
view.removeFromSuperview(), removeFromParent(), and finally nil-ing _controller;
then call cleanupController() from both deinit and the
eventTriggered/.presentationClosed handler to avoid double execution.

In `@purchasely/test/native_view_widget_test.dart`:
- Around line 203-235: The test currently only ensures no crash when switching
layout directions; add explicit assertions after each pump to verify the widget
is present and layout direction applied: after pumping the LTR MaterialApp and
RTL MaterialApp, call expectations such as
expect(find.byType(PLYPresentationView), findsOneWidget) and a
directional-dependent check (e.g., expect(find.byWidgetPredicate(w => /* check
Directionality or a child layout property tied to TextDirection */),
findsOneWidget)) so the test asserts the view is rendered and responds to
context; reference PLYPresentationView, testWidgets, tester.pumpWidget and
debugDefaultTargetPlatformOverride to locate where to insert the expectations.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 32490b05-cb16-4426-88b4-ee6eeffe2cf5

📥 Commits

Reviewing files that changed from the base of the PR and between b354f16 and d2e6116.

📒 Files selected for processing (5)
  • purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/NativeView.kt
  • purchasely/example/ios/RunnerTests/SwiftPurchaselyFlutterPluginTests.swift
  • purchasely/ios/Classes/NativeView.swift
  • purchasely/lib/native_view_widget.dart
  • purchasely/test/native_view_widget_test.dart

Comment thread purchasely/ios/Classes/NativeView.swift Outdated
Comment thread purchasely/ios/Classes/NativeView.swift Outdated
Comment thread purchasely/lib/native_view_widget.dart Outdated
Comment thread purchasely/ios/Classes/NativeView.swift Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes an iOS nested PLYPresentationView rendering issue where paywall content disappears after an orientation change followed by a tab switch, by improving native view controller embedding and relayout behavior; also includes small Android cleanup and Flutter RTL support.

Changes:

  • iOS: Wrap embedded controller view in a container view, add VC containment, and trigger relayout on orientation changes.
  • Flutter: Use inherited Directionality for Android platform view layout direction; remove debug prints.
  • Android: Implement dispose() cleanup and correct a fallback-path log message; add/update tests.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
purchasely/ios/Classes/NativeView.swift Adds container view + VC containment and forces relayout on orientation changes to prevent blank nested content.
purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/NativeView.kt Ensures native view cleanup on disposal and fixes fallback-path logging.
purchasely/lib/native_view_widget.dart Uses inherited Directionality for Android platform view layout direction; removes debug prints.
purchasely/test/native_view_widget_test.dart Adds a widget test intended to cover layout direction behavior.
purchasely/example/ios/RunnerTests/SwiftPurchaselyFlutterPluginTests.swift Adds iOS unit tests related to the new native view approach.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread purchasely/ios/Classes/NativeView.swift
Comment thread purchasely/ios/Classes/NativeView.swift
Comment thread purchasely/test/native_view_widget_test.dart Outdated
Comment thread purchasely/ios/Classes/NativeView.swift
Comment thread purchasely/test/native_view_widget_test.dart Outdated
Comment thread purchasely/ios/Classes/NativeView.swift
Comment thread purchasely/lib/native_view_widget.dart Outdated
Comment thread purchasely/ios/Classes/NativeView.swift Outdated
@kherembourg kherembourg requested a review from EPIKorial May 4, 2026 12:49
iOS:
- Stable container view in NoAnimationTransitionCoordinator (lazy
  stored property) to honor UIViewControllerTransitionCoordinatorContext
- Scene-aware host VC lookup with iOS 12 fallback
- Extract cleanupController() helper (deinit + presentationClosed)
- Begin/end device orientation notifications around the observer
- Drop unused NativeContainerView.controller property

Dart:
- Use Directionality.maybeOf(context) ?? TextDirection.ltr to avoid
  FlutterError when no Directionality ancestor exists

Tests:
- Assert AndroidView.layoutDirection in LTR/RTL contexts
- Add fallback test for missing Directionality
- Reset debugDefaultTargetPlatformOverride via try/finally so the
  invariant check sees an unset value

Android:
- Drop redundant (layout as ViewGroup) casts (FrameLayout is a ViewGroup)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@kherembourg
Copy link
Copy Markdown
Collaborator Author

@greptileai review

@kherembourg kherembourg merged commit f2cb915 into main May 4, 2026
10 checks passed
@kherembourg kherembourg deleted the fix/nested-view-rotation-layout branch May 4, 2026 13:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants