Skip to content

Remove restriction for displaying Popup on iOS#1369

Merged
TheCodeTraveler merged 20 commits intoCommunityToolkit:mainfrom
cat0363:Issue-1347
Oct 22, 2023
Merged

Remove restriction for displaying Popup on iOS#1369
TheCodeTraveler merged 20 commits intoCommunityToolkit:mainfrom
cat0363:Issue-1347

Conversation

@cat0363
Copy link
Copy Markdown
Contributor

@cat0363 cat0363 commented Aug 25, 2023

This PR makes it possible to use Popup when the display source of Popup is not PageHandler.

Description of Change

The SetElement method in MauiPopup.macios.cs imposes a restriction that the Parent type must be PageHandler.

[src\CommunityToolkit.Maui.Core\Views\Popup\MauiPopup.macios.cs]

public void SetElement(IPopup element)
{
// Remove start
    if (element.Parent?.Handler is not PageHandler)	
    {
        throw new InvalidOperationException($"The {nameof(element.Parent)} must be of type {typeof(PageHandler)}.");
    }
// Remove end
    
    omit ...
}

By removing this restriction, we fix it so that the Popup can be displayed even if the Popup's parent is not a PageHandler.

However, removing the above constraint causes the following problem.

  1. By making the background color of the page where the Popup is displayed semi-transparent,
    the background color of the Popup is dimmed to achieve the overlay. However, if the page
    from which the Popup is displayed is displayed modally, if the background color of the page
    displayed modally is made translucent, the page from which the page displayed modally is
    displayed will also be visible.

Below is the verification video.

iPhone.14.iOS.16.4.2023-08-25.11-12-35.mp4
  1. Closing the displayed Popup not only closes the Popup, but also closes the page from
    which it was displayed. As a result, it will transition to the display source page of
    the page that was displayed modally.

Below is the verification video.

iPhone.14.iOS.16.4.2023-08-25.11-35-54.mp4

To solve the first problem, stop making the page from which the Popup is displayed
semi-transparent to achieve the overlay. Add a new UIView to Subview to overlay on the
page where the Popup is displayed.

[src\CommunityToolkit.Maui.Core\Views\Popup\MauiPopup.macios.cs]

UIView overlayView;

public MauiPopup(IMauiContext mauiContext)
{
    this.mauiContext = mauiContext ?? throw new ArgumentNullException(nameof(mauiContext));
    overlayView = new UIView();
}

public override void ViewWillDisappear(bool animated)
{
    overlayView.RemoveFromSuperview();
    base.ViewWillDisappear(animated);
}

public override void ViewWillTransitionToSize(CGSize toSize, IUIViewControllerTransitionCoordinator coordinator)
{
    base.ViewWillTransitionToSize(toSize, coordinator);

    coordinator.AnimateAlongsideTransition((IUIViewControllerTransitionCoordinatorContext obj) =>
    {
        // Before screen rotate
        if (ViewController?.View is UIView view)
        {
            overlayView.Frame = new CGRect(view.Frame.X, view.Frame.Y, view.Frame.Width, view.Frame.Height);
        }
    }, (IUIViewControllerTransitionCoordinatorContext obj) =>
    {
        // After screen rotate
        if (VirtualView is not null)
        {
            PopupExtensions.SetSize(this, VirtualView);
            PopupExtensions.SetLayout(this, VirtualView);
        }
    });
}

void SetDimmingBackgroundEffect()
{
    if (ViewController?.View is UIView view)
    {
        overlayView.Bounds = view.Bounds;
        overlayView.Layer.RemoveAllAnimations();
        overlayView.Frame = view.Frame;
        overlayView.BackgroundColor = UIColor.Black.ColorWithAlpha(0.4f);
        view.AddSubview(overlayView);
    }
}

By detecting the rotation of the screen and specifying the size of the frame
after rotation before rotation, the overlay view is not animated by the resize
animation. I'm inverting the width and height.

coordinator.AnimateAlongsideTransition((IUIViewControllerTransitionCoordinatorContext obj) =>
{
    // Before screen rotate
    if (ViewController?.View is UIView view)
    {
        overlayView.Frame = new CGRect(view.Frame.X, view.Frame.Y, view.Frame.Width, view.Frame.Height);
    }
}, 

The above solves the first problem.

To solve the second problem, stop calling the ViewController's DismissViewControllerAsync method.

[src\CommunityToolkit.Maui.Core\Handlers\Popup\PopupHandler.macios.cs]

public static async void MapOnClosed(PopupHandler handler, IPopup view, object? result)
{
// Remove start
    var vc = handler.PlatformView.ViewController;
    if (vc is not null)
    {
        await vc.DismissViewControllerAsync(true);
    }
// Remove end
}

If the popup is displayed from a non-modally displayed page, the above will not be a problem,
but if the popup is displayed from a modally displayed page, it will be closed, including
the modally displayed page.

In order to properly close only the Popup, you need to change it as follows.

[src\CommunityToolkit.Maui.Core\Handlers\Popup\PopupHandler.macios.cs]

public static async void MapOnClosed(PopupHandler handler, IPopup view, object? result)
{
    var pc = handler.PlatformView.PresentationController;
    if (pc is not null)
    {
        if (pc.PresentedViewController is UIViewController pvc)
        {
            await pvc.DismissViewControllerAsync(true);
        }
    }
}

The above solves the second problem.

Linked Issues

PR Checklist

Additional information

I first verified it with the source code provided in Issue #1347.
Below are the verification results.

iPhone.14.iOS.16.4.2023-08-25.12-48-29.mp4

You can see that the page from which the Popup is displayed is translucent and the page behind it is no longer visible.
Also, when you close the Popup, you can see that the display source of the Popup is no longer closed.

Next, I tried rotating the screen while displaying the Popup.

iPhone.14.iOS.16.4.2023-08-25.12-49-52.mp4

You can see that the overlay resize animation does not occur and the overlay is displayed at its after rotated size.

Next, I displayed the Popup from a non-modal page and verified it.

iPhone.14.iOS.16.4.2023-08-25.13-05-23.mp4

You can see that the Popup appears and closes as intended.

@TheCodeTraveler
Copy link
Copy Markdown
Collaborator

@cat0363 Just FYI - the latest PR we merged today introduced a merge conflict in MauiPopup.macios.cs

@TheCodeTraveler
Copy link
Copy Markdown
Collaborator

@cat0363 Just FYI - a PR we merged today introduced a merge conflict here in this PR

@cat0363
Copy link
Copy Markdown
Contributor Author

cat0363 commented Aug 27, 2023

@brminnick , I resolved the conflict and merged.

Comment thread src/CommunityToolkit.Maui.Core/Views/Popup/MauiPopup.macios.cs Outdated
@pictos
Copy link
Copy Markdown
Member

pictos commented Aug 28, 2023

@cat0363 Thanks for this PR and all the others... But I don't think this is the correct solution, forcing the popup to open when we have a PageHandler is this way by design, this control doesn't support being opened for any other control type (Label, Entry, Button, ContentView, Popup, etc), so, I would say, your fix should be other... Or there's nothing here to fix. Maybe the fix should be to proxy to the Application.Current.MainPage when we don't find the current page, and I believe there's a merged PR that forces that

@cat0363
Copy link
Copy Markdown
Contributor Author

cat0363 commented Aug 29, 2023

@pictos , Thank you for your valuable opinion.
I was waiting for that opinion. I have a question for you.
I don't understand the design concept, so I'm sorry if it's an irrelevant question.

  1. Could you tell me the specific reasons for the following restrictions? And what problems does the lack of this restrictions create? I want to know that.

     public void SetElement(IPopup element)
     {
         if (element.Parent?.Handler is not PageHandler)
         {
             throw new InvalidOperationException($"The {nameof(element.Parent)} must be of type {typeof(PageHandler)}.");
         }
    
         VirtualView = element;
         ModalPresentationStyle = UIModalPresentationStyle.Popover;
    
         _ = View ?? throw new InvalidOperationException($"{nameof(View)} cannot be null.");
         _ = VirtualView ?? throw new InvalidOperationException($"{nameof(VirtualView)} cannot be null.");
    
         var rootViewController = WindowStateManager.Default.GetCurrentUIViewController() ?? throw new InvalidOperationException($"{nameof(PageHandler.ViewController)} cannot be null.");
         ViewController ??= rootViewController;
         SetDimmingBackgroundEffect();
     }
    
  2. Why don't Android and Windows have similar restrictions? I think that restrictions should be placed on Android and Windows so that there is no difference between what works and what doesn't work on each platform.

As an aside, the results of verification in PR before WeakReference is applied are described below.

[Case 1]

Open popup from popup. The first popup is displayed on the top left, the second popup is displayed in the center.

iPhone.14.iOS.16.4.2023-08-29.09-09-50.mp4

[Case 2]

Open a Popup with the click event of MyButton, which inherits the Button class.

public class MyButton : Button
{
    public MyButton()
    {
        this.Clicked += (sender, args) =>
        {
            var popup3 = new PopupPage3();
            Shell.Current.ShowPopup(popup3);
        };
    }
}
iPhone.14.iOS.16.4.2023-08-29.09-10-07.mp4

The above case works equally well on Android and Windows.

As a result of discussion, if it is determined that there is no need to fix it, I will withdraw this PR.

@pictos
Copy link
Copy Markdown
Member

pictos commented Aug 29, 2023

@cat0363 there are no bad questions at all, just ask as you need (:. I'll do my best do answer your questions, since it has been a long long time since I wrote those lines...

Could you tell me the specific reasons for the following restrictions? And what problems does the lack of this restrictions create? I want to know that.

iOS doesn't have a popup concept, like Android (Dialog) and Windows (Flyout) have, so we mimic the Popup on iOS creating a ViewController and presenting it as a UIModalPresentationStyle.Popover in order to that work the parent of the popup should be anything that behaves like a Page, so because of that there's a check to make sure the element.Parent handler is a PageHandler.

Why don't Android and Windows have similar restrictions? I think that restrictions should be placed on Android and Windows so that there is no difference between what works and what doesn't work on each platform.

Since Android and Windows have the concept of Popup, everything is handled by each platform. So, the limitation on iOS is to make the popup implementation there as close as possible to other platforms.

As I mentioned, has been a long time since I wrote these lines, maybe something changes that we can modify this code. Or even if Apple releases a popup control, we can use that instead.

@cat0363
Copy link
Copy Markdown
Contributor Author

cat0363 commented Aug 29, 2023

@pictos , Thank you for answering my question.

Since Android and Windows have the concept of Popup, everything is handled by each platform. So, the limitation on iOS is to make the popup implementation there as close as possible to other platforms.

I understand why Windows and Android don't impose restrictions. The concept is to bring the incapable side closer to the capable side as much as possible, rather than matching the incapable side to the capable side, isn't it?

iOS doesn't have a popup concept, like Android (Dialog) and Windows (Flyout) have, so we mimic the Popup on iOS creating a ViewController and presenting it as a UIModalPresentationStyle.Popover in order to that work the parent of the popup should be anything that behaves like a Page, so because of that there's a check to make sure the element.Parent handler is a PageHandler.

Is it correct to recognize that there are things that cannot be done if it is not PageHandler?
I would like to know what it is and the role of the Popup's parent.

In the issue that led to this PR, the type of element.Parent?.Handler was PhoneFlyoutPageRenderer.
It was not PageHandler. Does it mean that there is something that PhoneFlyoutPageRenderer
cannot achieve?

If the answer is yes, then you can add a condition, but the PhoneFlyoutPageRenderer cannot be
referenced as it is defined at:

Microsoft.Maui.Controls.Handlers.Compatibility.PhoneFlyoutPageRenderer

So the implementation would look something like this:

if (element.Parent?.Handler is not PageHandler &&
    element.Parent?.Handler?.GetType().Name != "PhoneFlyoutPageRenderer")
{
    throw new InvalidOperationException($"The {nameof(element.Parent)} must be of type {typeof(PageHandler)} or PhoneFlyoutPageRenderer.");
}

Wouldn't it be possible to add allowable parents as above?

@cat0363 cat0363 mentioned this pull request Aug 31, 2023
6 tasks
@TheCodeTraveler
Copy link
Copy Markdown
Collaborator

@cat0363 FYI - after merging #1361, it has caused a merge conflict on this PR

@cat0363
Copy link
Copy Markdown
Contributor Author

cat0363 commented Sep 30, 2023

@brminnick , I resolved the conflict. But, I got a build error after merging, I will fix it on Monday.

This PR does not give a clear answer to the question. I need to know a clear reason why element.Parent must be a PageHandler.
We need to write and verify an example of what happens when element.Parent is not a PageHandler. However, I haven't been able to describe the NG cases.

I think MacCatalyst at least needs to be considered separately from iOS since it is limited to PageHandler in PR #1409.
The build error seems to occur because this PR does not take into account the fixes in PR #1409.

@cat0363
Copy link
Copy Markdown
Contributor Author

cat0363 commented Oct 2, 2023

For MacCatalyst, element.Parent must be a PageHandler, so we reinstated the restriction.

[src\CommunityToolkit.Maui.Core\Views\Popup\MauiPopup.macios.cs]

public void SetElement(IPopup element)
{
    if (element.Parent?.Handler is not PageHandler)
    {
        throw new InvalidOperationException($"The {nameof(element.Parent)} must be of type {typeof(PageHandler)}.");
    }

    VirtualView = element;
    ModalPresentationStyle = UIModalPresentationStyle.Popover;

    _ = View ?? throw new InvalidOperationException($"{nameof(View)} cannot be null.");
    _ = VirtualView ?? throw new InvalidOperationException($"{nameof(VirtualView)} cannot be null.");

#if MACCATALYST
    var pagehandler = VirtualView.Parent.Handler as PageHandler;
    var rootViewController = pagehandler?.ViewController ?? WindowStateManager.Default.GetCurrentUIViewController() ?? throw new InvalidOperationException($"{nameof(PageHandler.ViewController)} cannot be null.");
#else
    var rootViewController = WindowStateManager.Default.GetCurrentUIViewController() ?? throw new InvalidOperationException($"{nameof(PageHandler.ViewController)} cannot be null.");
#endif

    ViewController ??= rootViewController;
    SetDimmingBackgroundEffect();
}

In the case of MacCatalyst, if you set overlayView.Frame = view.Frame, the overlay does not completely cover the original view, so I fixed it as follows. For MacCatalyst, this is because there is an offset in the Y coordinate.

[src\CommunityToolkit.Maui.Core\Views\Popup\MauiPopup.macios.cs]

void SetDimmingBackgroundEffect()
{
    if (ViewController?.View is UIView view)
    {
        var overlayView = GetOverlayView(view);
        overlayView.Bounds = view.Bounds;
        overlayView.Layer.RemoveAllAnimations();
        overlayView.Frame = new CGRect(0, 0, view.Frame.Width, view.Frame.Height);
        overlayView.BackgroundColor = UIColor.Black.ColorWithAlpha(0.4f);
        view.AddSubview(overlayView);
    }
}

public override void ViewWillTransitionToSize(CGSize toSize, IUIViewControllerTransitionCoordinator coordinator)
{
    coordinator.AnimateAlongsideTransition((IUIViewControllerTransitionCoordinatorContext obj) =>
    {
        // Before screen rotate
        if (ViewController?.View is UIView view)
        {
            var overlayView = GetOverlayView(view);
            overlayView.Frame = new CGRect(0, 0, view.Frame.Width, view.Frame.Height);
        }
    }, (IUIViewControllerTransitionCoordinatorContext obj) =>
    {
        // After screen rotate
        if (VirtualView is not null)
        {
            PopupExtensions.SetSize(this, VirtualView);
            PopupExtensions.SetLayout(this, VirtualView);
        }
    });
    base.ViewWillTransitionToSize(toSize, coordinator);
}

Changed null judgment from ==null to is null.

[src\CommunityToolkit.Maui.Core\Views\Popup\MauiPopup.macios.cs]

UIView GetOverlayView(UIView view)
{
    var overlayViewTag = new IntPtr(38483);
    var overlayView = view.Subviews.AsEnumerable()
        .Where(
            x => x.Tag == overlayViewTag
        )
        .FirstOrDefault();
    if (overlayView is null)
    {
        overlayView = new UIView();
        overlayView.Tag = overlayViewTag;
    }

    return overlayView;
}

Copy link
Copy Markdown
Collaborator

@TheCodeTraveler TheCodeTraveler left a comment

Choose a reason for hiding this comment

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

Great improvements! Thanks @cat0363!!

@TheCodeTraveler TheCodeTraveler enabled auto-merge (squash) October 22, 2023 11:26
@TheCodeTraveler TheCodeTraveler added the hacktoberfest-accepted A PR that has been approved during Hacktoberfest label Oct 22, 2023
@TheCodeTraveler TheCodeTraveler merged commit ecc3dc7 into CommunityToolkit:main Oct 22, 2023
@cat0363 cat0363 mentioned this pull request Jan 19, 2024
6 tasks
@github-actions github-actions Bot locked and limited conversation to collaborators Nov 20, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

hacktoberfest-accepted A PR that has been approved during Hacktoberfest

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] InvalidOperationException on iOS when opening Popup from FlyoutPage which is opened as modal

3 participants