adospace / reactorui-maui

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

Render not triggered. #135

Closed danlfarnell closed 1 year ago

danlfarnell commented 1 year ago

Hi, I have a button component which is on a page. I want to use a prop to enable the button when proper validation has been completed. I can see that true is passed to the IsEnabled Prop but the Render method doesn't get called again to enable the button. My code looks like this. I have read in other posts that you can just set the field directly instead of calling SetState(). Any suggestions? Thanks in advance.

 private bool _isEnabled; 

internal class SignUpButton : Component
{

  public SignUpButton IsEnabled(bool isEnabled)
    {
        _isEnabled = isEnabled;
        return this;
    }

  public override VisualNode Render() <====Doesn't get triggered when _isEnabled is set.
    {
        return new Button()
            .Text("Sign up")
            .Class("NextButton")
            .WidthRequest(300)
            .HCenter()
            .IsEnabled(_isEnabled)
            .OnClicked(OnSignUpButtonClicked);
    }

}
adospace commented 1 year ago

Your component is included in a render tree? Can I see how you use the component?

danlfarnell commented 1 year ago

Sure no problem. In summary I use a email and password entry. When they are both valid, true gets sent to the SignUpButton Component. I love components :). Thanks again.


internal class CreateAccountPage : Component<CreateAccountState, CreateAccountProps>
{
    private readonly IParameter<CreateAccountPageParameters> _parameters;

    public CreateAccountPage()
    {
        _parameters = CreateParameter<CreateAccountPageParameters>();
    }

    public override VisualNode Render()
    {
        return new ContentPage()
        {
            new VStack(spacing: 20)
            {
                new Label("Create Your Account").Class("LargeLabel").HCenter(),
                new EmailEntry()
                    .OnTextChanged((text, validated) =>
                        SetState(s =>
                            {
                                if (!validated) return;

                                s.Email = text;
                                s.EmailValid = true;
                            }
                        )),
                new PasswordEntry()
                    .OnTextChanged((text, validated) =>
                    {
                        SetState(s =>
                        {
                            if (!validated) return;

                            s.Password = text;
                            s.PasswordValid = true;
                        });
                    }),
                new HStack(spacing: 5)
                {
                    new CheckBox().VCenter().OnCheckedChanged(OnRememberMeChanged),
                    new Label().Class("SmallLabel").Text("Remember Me").VCenter(),
                }.HCenter(),
                new SignUpButton()
                    .PageToNavigateTo(PageNames.SignUpPage)
                    .IsEnabled(State.EmailValid && State.PasswordValid), <== true is passed but button remains disabled.
                new DividerWithTextComponent("or continue with"),
                new HStack(spacing: 20)
                {
                    new FaceBookButtonComponent().Compact(true),
                    new GoogleButtonComponent().Compact(true),
                    new AppleButtonComponent().Compact(true)
                }.HCenter(),
                new SignInLink(),
            }.Padding(10).Margin(0, 80, 0, 0)
        }.Class("Page");
    }

    private void OnRememberMeChanged(object arg1, MauiControls.CheckedChangedEventArgs arg2)
    {
        _parameters.Set(_ => _.RememberMe = arg2.Value);
    }
}```
adospace commented 1 year ago

Hum, from what I see it should work just fine. Just a few hints: 1) The OnTextChanged is called "after" the text is changed (i.e. on lost focus), so maybe the button is not enabled as user types text but only after he moves focus away? 2) when you say that "State.EmailValid && State.PasswordValid" are both true, did you see it in the debugger? is the value passed to the IsEnabled method of the SignUpButton and its Render method is not called?

It would be much better if you could repro the issue in a small project that I can run.

danlfarnell commented 1 year ago

Hi Ado, In regards to the questions posted.

  1. Each time I type a character in either the email or password entry, OnChanged is fired.
  2. Yes, I have seen this in the debugger. When both the Email and Password are Valid it passes in true to the SignUpButton IsEnabled prop. I can see that _isEnabled is changed to true inside the SignUpButton component, however the breakpoint I have on the Render method never gets triggered.

I have posted a small demo @ https://github.com/danlfarnell/MauiReactorTestApp.git for your review. Thanks again for all your help!!!

adospace commented 1 year ago

Ok, I see the problem: you're adding more than one time the same behavior to the entry and also you aren't checking the entry reference to be null. This is the fixed code:

new Entry(ent =>
{
    if (ent != null &&
        ent.Behaviors.Contains(_emailValidationBehavior))
    {
        ent.Behaviors.Add(_emailValidationBehavior);
    }
})

BUT SEE BELOW

Using behaviors is not recommended in MauiReactor because they are just another MVVM "thing" to overcome a problem in the MVVM approach that it's instead solved pretty easily in MVU.

For example, this is how I would rewrite your code in a pure MVU approach:

class MainPageState
{
    public string Email { get; set; }
    public string Password { get; set; }
}

class MainPage : Component<MainPageState>
{
    public override VisualNode Render()
    {
        return new ContentPage
        {
            new VStack(spacing: 20)
            {
                new Label("Create Your Account").Class("LargeLabel").HCenter(),
                new EmailEntry()
                    .OnEmailSet(email => SetState(s => s.Email = email)),
                new PasswordEntry()
                    .OnPasswordSet(password => SetState(s => s.Password = password)),
                new SignUpButton()
                    .PageToNavigateTo("SignUpPage")
                    .IsEnabled(State.Email != null && State.Password != null),

            }.Padding(10).Margin(0, 80, 0, 0)
        }.Class("Page");

    }
}

class EmailEntryState
{
    public bool IsValid { get; set; }
}

class EmailEntry : Component<EmailEntryState>
{
    private static readonly Regex _validationRegex = new(@"^([\w\.\-]+)@([\w\-]+)((\.(\w){2,3})+)$");
    private bool _isThinIcon;
    private Action<string> _onEmailSet;

    public EmailEntry UseThinIcon(bool useThinIcon)
    {
        _isThinIcon = useThinIcon;
        return this;
    }

    public EmailEntry OnEmailSet(Action<string> action)
    {
        _onEmailSet = action;
        return this;
    }

    public override VisualNode Render()
    {
        return _isThinIcon ? RenderThinIcon() : RenderRegularIcon();
    }

    private VisualNode RenderThinIcon()
    {
        return new Border
        {
            new Grid()
            {
                new Entry()
                    .Placeholder("Email").Class("NormalEntry")
                    .HFill()
                    .GridColumn(0)
                    .IsTextPredictionEnabled(false)
                    .OnTextChanged(OnEmailTextChanged)
                    .TextColor(State.IsValid ? Colors.White : Colors.Gray)
                    ,

                new Image()
                    .Source("message_icon_thin")
                    .GridColumn(1)
                    .HEnd(),
            }.Columns("*,Auto")
        }.Class("ControlBorder");
    }

    private VisualNode RenderRegularIcon()
    {
        return new Border
        {
            new HStack(spacing: 5)
            {
                new Image().Source("email_icon"),
                new Entry()
                    .Placeholder("Email").Class("NormalEntry")
                    .WidthRequest(300)
                    .IsTextPredictionEnabled(false)
                    .OnTextChanged(OnEmailTextChanged)
                    .TextColor(State.IsValid ? Colors.White : Colors.Gray)
            }
        }.Class("ControlBorder");
    }

    private void OnEmailTextChanged(object sender, MauiControls.TextChangedEventArgs args)
    {
        SetState(s => s.IsValid = _validationRegex.IsMatch(args.NewTextValue));

        _onEmailSet?.Invoke(State.IsValid ? args.NewTextValue : null);
    }
}

class PasswordEntryState
{
    public bool IsValid { get; set; }
}

class PasswordEntry : Component<PasswordEntryState>
{
    private Action<string> _onPasswordSet;

    public PasswordEntry OnPasswordSet(Action<string> action)
    {
        _onPasswordSet = action;
        return this;
    }

    public override VisualNode Render()
    {
        return new Border
        {
            new HorizontalStackLayout(spacing: 5)
            {
                new Image().Source("password_icon"),
                new Entry()
                    .Placeholder("Password")
                    .Class("NormalEntry")
                    .IsPassword(true).WidthRequest(300)
                    .OnTextChanged(OnPasswordTextChanged)
                    .TextColor(State.IsValid ? Colors.White : Colors.Gray)
            }
        }.Class("ControlBorder");
    }

    static bool ValidatePassword(string password)
    {
        const int MIN_LENGTH = 8;
        const int MAX_LENGTH = 15;

        if (password == null) throw new ArgumentNullException();

        bool meetsLengthRequirements = password.Length >= MIN_LENGTH && password.Length <= MAX_LENGTH;
        bool hasUpperCaseLetter = false;
        bool hasLowerCaseLetter = false;
        bool hasDecimalDigit = false;

        if (meetsLengthRequirements)
        {
            foreach (char c in password)
            {
                if (char.IsUpper(c)) hasUpperCaseLetter = true;
                else if (char.IsLower(c)) hasLowerCaseLetter = true;
                else if (char.IsDigit(c)) hasDecimalDigit = true;
            }
        }

        bool isValid = meetsLengthRequirements
                    && hasUpperCaseLetter
                    && hasLowerCaseLetter
                    && hasDecimalDigit
                    ;
        return isValid;

    }
    private void OnPasswordTextChanged(object arg1, MauiControls.TextChangedEventArgs arg2)
    {
        SetState(s => s.IsValid = !ValidatePassword(arg2.NewTextValue));

        _onPasswordSet?.Invoke(State.IsValid ? arg2.NewTextValue : null);
    }
}
danlfarnell commented 1 year ago

Thanks Ado, that did the trick!!!. I prefer the MVU approach over MVVM with XAML any day. I will go with the full MVU approach you suggested. Thanks again and great work on this project!!!.