Maui Custom Entry Behavior is blocking blocking Set call on ViewModel

129 Views Asked by At

I have created a custom behavior for the Entry control that allows a collection of Entry controls to provide a single-digit code input field that moves the cursor to the next field ( to the right ) when a value is entered, and to the previous field ( to the left ) when a value is deleted. This mostly works. There are still some bugs to work out in the actual behavior. The issue I am trying to resolve right now is that the string array that is the backing field in the ViewModel is not getting set calls, which means I can't raise property change notifications.

The custom behavior code is listed below..

public class AutoFocusBehavior : Behavior<Entry>
{
    private string previousText = string.Empty;

    public static readonly BindableProperty EntryTextChangedProperty =
        BindableProperty.Create(nameof(EntryTextChanged),
                                typeof(EventHandler<TextChangedEventArgs>),
                                typeof(AutoFocusBehavior),
                                null);

    public event EventHandler<TextChangedEventArgs> EntryTextChanged;

    protected override void OnAttachedTo(Entry entry)
    {
        base.OnAttachedTo(entry);

        entry.Focused += OnEntryFocused;
        entry.TextChanged += OnEntryTextChanged;
    }

    protected override void OnDetachingFrom(Entry entry)
    {
        base.OnDetachingFrom(entry);
        entry.Focused -= OnEntryFocused;
        entry.TextChanged -= OnEntryTextChanged;
    }

    private void OnEntryFocused(object sender, FocusEventArgs e)
    {
        if (sender is Entry entry)
        {
            // Store the previous text if it's not null or empty
            if (!string.IsNullOrEmpty(entry.Text))
            {
                previousText = entry.Text;
            }

            // Select all text when the Entry is focused
            entry.SelectionLength = entry.Text?.Length ?? 0;
        }
    }

    private void OnEntryTextChanged(object sender, TextChangedEventArgs e)
    {
        if (sender is Entry entry)
        {
            if (e.NewTextValue.Length == 0 && e.OldTextValue.Length == 1)
            {
                // If the user deletes a character, move focus to the previous entry
                if (!MoveFocusToPreviousEntry(entry))
                {
                    // If it's the first entry, do not move focus
                    entry.Text = string.Empty;
                }
            }
            else if (e.NewTextValue.Length == 1)
            {
                // If the text length is 1, move focus to the next entry and clear the current entry
                entry.Unfocus();
                MoveFocusToNextEntry(entry);
            }

            EntryTextChanged?.Invoke(sender, e);
        }
    }


    private void MoveFocusToNextEntry(Entry currentEntry)
    {
        // Find the parent stack layout containing all the Entry elements
        if (currentEntry.Parent is StackLayout stackLayout)
        {
            // Get the index of the current Entry
            int currentIndex = stackLayout.Children.IndexOf(currentEntry);

            // Move focus to the next Entry if it exists
            if (currentIndex < stackLayout.Children.Count - 1)
            {
                Entry nextEntry = (Entry)stackLayout.Children[currentIndex + 1];
                nextEntry.Focus();
            }
        }
    }

    private bool MoveFocusToPreviousEntry(Entry currentEntry)
    {
        // Find the parent stack layout containing all the Entry elements
        if (currentEntry.Parent is StackLayout stackLayout)
        {
            // Get the index of the current Entry
            int currentIndex = stackLayout.Children.IndexOf(currentEntry);

            // If it's the first Entry and the text is empty, do not move focus
            if (currentIndex == 0 && string.IsNullOrEmpty(currentEntry.Text))
            {
                return false;
            }

            // Move focus to the previous Entry if it exists
            if (currentIndex > 0)
            {
                Entry previousEntry = (Entry)stackLayout.Children[currentIndex - 1];
                previousEntry.Focus();
                return true;
            }
        }

        return false;
    }
}

The XAML that this code is bound to is

<StackLayout x:Name="EntryStack" Orientation="Horizontal" HorizontalOptions="CenterAndExpand" Spacing="1" Grid.Row="1" Margin="0, 10">
  <!-- Code Entry Boxes -->
  <Entry WidthRequest="40" Text="{Binding CodeDigits[0], Mode=TwoWay}" Keyboard="Numeric" MaxLength="1" HorizontalTextAlignment="Center">
    <Entry.Behaviors>
      <b:AutoFocusBehavior EntryTextChanged="{Binding EntryTextChangedCommand}" />
    </Entry.Behaviors>
  </Entry>
  <Entry WidthRequest="40" Text="{Binding CodeDigits[1], Mode=TwoWay}" Keyboard="Numeric" MaxLength="1" HorizontalTextAlignment="Center">
    <Entry.Behaviors>
      <b:AutoFocusBehavior EntryTextChanged="{Binding EntryTextChangedCommand}" />
    </Entry.Behaviors>
  </Entry>
  <Entry WidthRequest="40" Text="{Binding CodeDigits[2], Mode=TwoWay}" Keyboard="Numeric" MaxLength="1" HorizontalTextAlignment="Center">
    <Entry.Behaviors>
      <b:AutoFocusBehavior EntryTextChanged="{Binding EntryTextChangedCommand}" />
    </Entry.Behaviors>
  </Entry>
  <Entry WidthRequest="40" Text="{Binding CodeDigits[3], Mode=TwoWay}" Keyboard="Numeric" MaxLength="1" HorizontalTextAlignment="Center">
    <Entry.Behaviors>
      <b:AutoFocusBehavior EntryTextChanged="{Binding EntryTextChangedCommand}" />
    </Entry.Behaviors>
  </Entry>
  <Entry WidthRequest="40" Text="{Binding CodeDigits[4], Mode=TwoWay}" Keyboard="Numeric" MaxLength="1" HorizontalTextAlignment="Center">
    <Entry.Behaviors>
      <b:AutoFocusBehavior EntryTextChanged="{Binding EntryTextChangedCommand}" />
    </Entry.Behaviors>
  </Entry>
  <Entry WidthRequest="40" Text="{Binding CodeDigits[5], Mode=TwoWay}" Keyboard="Numeric" MaxLength="1" HorizontalTextAlignment="Center">
    <Entry.Behaviors>
      <b:AutoFocusBehavior EntryTextChanged="{Binding EntryTextChangedCommand}" />
    </Entry.Behaviors>
  </Entry>
  <Entry WidthRequest="40" Text="{Binding CodeDigits[6], Mode=TwoWay}" Keyboard="Numeric" MaxLength="1" HorizontalTextAlignment="Center">
    <Entry.Behaviors>
      <b:AutoFocusBehavior EntryTextChanged="{Binding EntryTextChangedCommand}" />
    </Entry.Behaviors>
  </Entry>
  <Entry WidthRequest="40" Text="{Binding CodeDigits[7], Mode=TwoWay}" Keyboard="Numeric" MaxLength="1" HorizontalTextAlignment="Center">
    <Entry.Behaviors>
      <b:AutoFocusBehavior EntryTextChanged="{Binding EntryTextChangedCommand}" />
    </Entry.Behaviors>
  </Entry>
</StackLayout>

The Property the view model is binding to is ...

 public string[] CodeDigits
 {
    get { return _codeDigits; }
    set
    {
        if (_codeDigits != value)
        {
            _codeDigits = value;
            RaisePropertyChanged(nameof(CodeDigits));
            RaisePropertyChanged(nameof(IsVerifyButtonEnabled));
        }
    }
 }

I have tried a number of different things to get the setter fired, including adding the BindableProperty for the behavior and manually firing the EntryTextChanged event from the behaviors own OnEntryTextChanged handler. I am missing something fundamental here. Any help/guidance is appreciated :-)

2

There are 2 best solutions below

0
Riccardo Minato On

Your setter is not called because it belongs to CodeDigits property, not to the strings contained in it.

You may try using ObservableCollection instead of string[], but I'm not sure it would change something.

I see two ways.

If you know how many entries you need, let's say 8, you can use 8 string properties and call RaisePropertyChanged for each of them.

Otherwise, you have to create a more complex object that implement INotifyPropertyChanged and has a string property and then you store 8 of these objects in the array.

0
Liqun Shen-MSFT On

The elements in CodeDigits have been changed though the setter is not fired. The setter will fire only when the entire CodeDigits changes.

If you want to raise property changed event in ViewModel, one of the easiest way I think is using Entry's TextChanged event. For convenience, you could use EventToCommandBehavior from .NET MAUI Community Toolkit.

        <Entry WidthRequest="40" Text="{Binding CodeDigits[0], Mode=TwoWay}" Keyboard="Numeric" MaxLength="1" HorizontalTextAlignment="Center" 
           >
            <Entry.Behaviors>
                <b:AutoFocusBehavior EntryTextChanged="{Binding EntryTextChangedCommand}" />
                <toolkit:EventToCommandBehavior
                EventName="TextChanged"
                Command="{Binding MyEntryTextChangedCommand}" />
            </Entry.Behaviors>
        </Entry>

And in viewModel, you could RaisePropertyChanged.Because the elements in CodeDigits have been changed, you don't have to RaisePropertyChanged for CodeDigits.

    public Command MyEntryTextChangedCommand
    {
        get => new Command(() =>
        {
            //Add the logic here
            RaisePropertyChanged(nameof(IsVerifyButtonEnabled));
        });
    }

Besides, you could also refer to Consume a .NET MAUI behavior with a style, which could make your code more clean.

Another community also mentioned using ObservableCollection.I am not quite sure if it works. Because the Setter method of ObservableCollection will not be fired when a property of an item changes. Please refer to this thread : C#, Xamarin Forms - ObservableCollection of custom Object updates but does not fire property changed events. Also, you have to change the UI a bit more. You may use a CollectionView or BindableLayout and set the ItemsSource for it.

Please let me know if you have any question.