adospace / reactorui-maui

MauiReactor is a MVU UI framework built on top of .NET MAUI
MIT License
555 stars 46 forks source link

iOS state stops working after Modal Page #179

Closed Code-DJ closed 6 months ago

Code-DJ commented 7 months ago

With a NavigationPage based routing (haven't tried with Shell), if you open a modal page using Navigation.PushModalAsync, the state on the parent page stops working.

The following is a modified version of the TestApp (2.0.0-beta + .net 8.0):

namespace Foo;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiReactorApp<StartPage>()
#if DEBUG
            .EnableMauiReactorHotReload()
#endif
            ;

        return builder.Build();
    }
}

public class StartPage : Component
{
    public override VisualNode Render() => new NavigationPage()
    {
        new ContentPage()
        {
            new Button("Move To Main Page")
                .OnClicked(async () => await Navigation!.PushAsync<MainPage>())
        }
    };
}

public class MainPageState
{
    public bool IsLabelVisible { get; set; }
}

public class MainPage : Component<MainPageState>
{
    public override VisualNode Render()
    {
        return new ContentPage()
        {
            new StackLayout()
            {
                new Label()
                    .Text("Peek a Boo")
                    .IsVisible(State.IsLabelVisible),

                new Button()
                    .Text("Toggle Label")
                    .OnClicked(() => SetState(state => state.IsLabelVisible = !state.IsLabelVisible)),

                new Button("Open Child Page")
                    .OnClicked(async () => await Navigation!.PushModalAsync<ChildPage>())
            }
            .VCenter()
            .HCenter()
        }
        .Title("Main Page");
    }
}

public class ChildPage : Component
{
    public override VisualNode Render() => new NavigationPage()
    {
        new ContentPage()
        {
            new Button("Back")
                .VCenter()
                .HCenter()
                .OnClicked(async () => await Navigation!.PopModalAsync())
        }
        .Title("Child Page")
    }
    .OniOS(page => page.Set(MauiControls.PlatformConfiguration.iOSSpecific.Page.ModalPresentationStyleProperty, MauiControls.PlatformConfiguration.iOSSpecific.UIModalPresentationStyle.FormSheet));
}
Code-DJ commented 7 months ago

Hi @adospace thanks for the quick fix. It appears there is still something missing. At least when I use the latest main branch.

The original issue is resolved - opening a modal dialog and closing it - the state continues to work. From the Main Page, if you navigate to the Interim Page and come back, the state stops working.

public class Bug179StartPage : Component
 {
     public override VisualNode Render() => new NavigationPage()
     {
         new ContentPage()
         {
             new Button("Move To Main Page")
                 .OnClicked(async () => await Navigation!.PushAsync<Bug179MainPage>())
         }
     };
 }

 public class Bug179MainPageState
 {
     public bool IsLabelVisible { get; set; }
 }

 public class Bug179MainPage : Component<Bug179MainPageState>
 {
     public override VisualNode Render()
     {
         return new ContentPage()
         {
             new StackLayout()
             {
                 new Label()
                     .Text("Peek a Boo")
                     .IsVisible(State.IsLabelVisible),

                 new Button()
                     .Text("Toggle Label")
                     .OnClicked(() => SetState(state => state.IsLabelVisible = !state.IsLabelVisible)),

                new Button("Go to Interim Page")
                    .OnClicked(async () => await Navigation!.PushAsync<Bug179InterimPage>()),

                 new Button("Open Child Page")
                     .OnClicked(async () => await Navigation!.PushModalAsync<Bug179ChildPage>())
             }
             .VCenter()
             .HCenter()
         }
         .Title("Main Page");
     }
 }

public class Bug179InterimPage : Component
{
    public override VisualNode Render()
    {
        return new ContentPage()
        {
        }
        .Title("Interim Page");
    }
}

 public class Bug179ChildPage : Component
 {
     public override VisualNode Render() => new NavigationPage()
     {
         new ContentPage()
         {
             new Button("Back")
                 .VCenter()
                 .HCenter()
                 .OnClicked(async () => await Navigation!.PopModalAsync())
         }
         .Title("Child Page")
     }
     .OniOS(page => page.Set(MauiControls.PlatformConfiguration.iOSSpecific.Page.ModalPresentationStyleProperty, MauiControls.PlatformConfiguration.iOSSpecific.UIModalPresentationStyle.FormSheet));
}
adospace commented 7 months ago

Sorry, too rushed, looking again, this time I'll let you test the fix before publishing the new version 😉

adospace commented 7 months ago

Hi, the Main branch contains the fix for this nasty bug, please let me know if it still doesn't cover all the cases.

A bit of background and why it's so hard to completely fix this issue:

This is a serious bug that I introduced a few versions ago when I tried to fix an unwanted behavior of the component WillUnmount event (see #164).

The problem was that when you navigated away from a page (navigating back) you didn't receive the WillUnmount event on the component rendering the page.

For example, considering:

class myPageComponent : Component
{

      override void OnWillUnmount()
      {
      //was never called
      }

      override OnRender()=> new ContentPage("my page");
}

My wrong understanding was that attaching to the Disappearing event of the page was enough to unmount the component. This was not the case. Even attaching to the Unload event of the page was sufficient because I just discovered the MAUI loads/unloads the same page when you navigate to and back to the page.

Now, I internal cache the list of pages created, and every time the developer navigates to a different page I check if any page has been removed from the navigation stack, and only then I unmount the component.

Thanks for having reported it

Code-DJ commented 7 months ago

Yay! it works. Thank you for the quick turnaround and the detailed explanation 🙏.

Code-DJ commented 6 months ago

Hi @adospace there is still a state related issue for modal dialogs - tried it on iOS - 2.0.5-beta.

Run the following with .UseMauiReactorApp<StartPage>():

public class StartPage : Component
{
    public override VisualNode Render() => new NavigationPage()
    {
        new ContentPage()
        {
            new Button("Open Child Page")
                .OnClicked(async () => await Navigation!.PushModalAsync<ChildPage>())
        }
    };
}

public class ChildPageState
{
    public bool IsFlyoutExpanded { get; set; }
}

public class ChildPage : Component<ChildPageState>
{
    public override VisualNode Render() => new NavigationPage()
    {
        new ContentPage(page =>
        {
            AddToolbarItem(page!, "Flyout", (s, e) => SetState(state => state.IsFlyoutExpanded = !state.IsFlyoutExpanded));
            AddToolbarItem(page!, "Close", async (s, e) => await page!.Navigation!.PopModalAsync());
        })
        {
            new Grid
            {
                new Flyout()
                    .IsExpanded(State.IsFlyoutExpanded)
                    .ExpandedChangedAction((isExpanded) => SetState(state => state.IsFlyoutExpanded = isExpanded))
            }
        }
        .Title("Child Page")
        .BackgroundColor(Colors.Aquamarine)
    }
    .OniOS(page => page.Set(MauiControls.PlatformConfiguration.iOSSpecific.Page.ModalPresentationStyleProperty, MauiControls.PlatformConfiguration.iOSSpecific.UIModalPresentationStyle.FormSheet));

    MauiControls.ToolbarItem AddToolbarItem(MauiControls.Page page, string text, EventHandler? clicked = null)
    {
        if (!page.ToolbarItems.Any(i => i.Text == text))
        {
            var toolbarItem = new MauiControls.ToolbarItem
            {
                Text = text
            };

            if (clicked != null)
                toolbarItem.Clicked += clicked;

            page.ToolbarItems.Add(toolbarItem);

            return toolbarItem;
        }

        return null!;
    }
}

class FlyoutState : EntitySetPageState<Note>
{
    public bool IsExpanded { get; set; }
}

class Flyout : Component<FlyoutState>
{
    const int FlyoutAnimationSpeed = 200;

    private bool isExpanded;
    private Action<bool>? expandedChangedAction;

    public Flyout IsExpanded(bool isExpanded)
    {
        this.isExpanded = isExpanded;
        return this;
    }

    public Flyout ExpandedChangedAction(Action<bool>? expandedChangedAction)
    {
        this.expandedChangedAction = expandedChangedAction;
        return this;
    }

    protected override void OnMountedOrPropsChanged()
    {
        base.OnMountedOrPropsChanged();

        SetState(state => state.IsExpanded = isExpanded);
    }

    public override VisualNode Render()
    {
        var layout = new AbsoluteLayout
        {
            RenderBackdrop(),
            RenderBody()
        };

        if (State.IsExpanded)
        {
            layout.HFill();
            layout.VFill();
        }
        else
        {
            layout.WidthRequest(1);
            layout.HeightRequest(1);
            layout.VStart();
            layout.HEnd();
        }

        return layout;
    }

    private Grid RenderBody() => new Grid
    {
        new Frame
        {
            new StackLayout()
            {
                new Button("Open Child Page")
                    .OnClicked(async () => await Navigation!.PushModalAsync<ChildPage>())
            }
        }
        .WidthRequest(300)
        .HeightRequest(400)
        .HasShadow(false)
        .BorderColor(Colors.LightGray)
    }
    .Padding(10)
    .Opacity(State.IsExpanded ? 1 : 0)
    .Scale(State.IsExpanded ? 1 : 0.8)
    .WithAnimation(easing: Easing.SpringOut, duration: FlyoutAnimationSpeed)
    .AbsoluteLayoutBounds(new Rect(1, 0, MauiControls.AbsoluteLayout.AutoSize, MauiControls.AbsoluteLayout.AutoSize))
    .AbsoluteLayoutFlags(AbsoluteLayoutFlags.PositionProportional);

    Grid RenderBackdrop() => new Grid()
        .BackgroundColor(AppColors.PageBackgroundColor)
        .Opacity(State.IsExpanded ? 0.5f : 0)
        .WithAnimation(easing: Easing.CubicOut, duration: FlyoutAnimationSpeed)
        .AbsoluteLayoutBounds(new Rect(0, 0, 1, 1))
        .AbsoluteLayoutFlags(AbsoluteLayoutFlags.All)
        .OnTapped(() =>
        {
            SetState(state => state.IsExpanded = false);
            expandedChangedAction?.Invoke(State.IsExpanded);
        });
}
adospace commented 6 months ago

Hi, thanks for reporting it (again). I've fixed the bug in the Beta-7 version online, please update your references and let me know if it's working for you too.

Side note, this is how to use ToolbarItems in MauiReactor:

    public override VisualNode Render() => new NavigationPage()
    {
        new ContentPage()
        {
            new ToolbarItem("Flyout")
                .OnClicked(()=>SetState(state => state.IsFlyoutExpanded = !state.IsFlyoutExpanded)),

            new ToolbarItem("Close")
                .OnClicked(()=>Navigation?.PopModalAsync()),

            new Grid
            {
                new Bug179_2Flyout()
                    .IsExpanded(State.IsFlyoutExpanded)
                    .ExpandedChangedAction((isExpanded) => SetState(state => state.IsFlyoutExpanded = isExpanded))
            }
        }
        .Title("Child Page")
        .BackgroundColor(Colors.Aquamarine)
    };
Code-DJ commented 6 months ago

Hi!, it works with 2.0.7-beta. Thank you!. Also, thanks for the tip on Toolbar. Closing issue.