maui icon indicating copy to clipboard operation
maui copied to clipboard

Unintended focus control occurs in NavigationPage and Shell.

Open cat0363 opened this issue 1 year ago • 8 comments

Discussed in https://github.com/dotnet/maui/discussions/14210

Originally posted by cat0363 March 27, 2023 If NavigationPage is specified for MainPage, unintended focus control will occur. If Shell is specified for MainPage, unintended focus control will not occur. To verify this problem, I created the screen layout shown below.

https://github.com/cat0363/Maui-Issue14211

[MainPage.xaml]

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
         xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
         x:Class="NavigationPageFocusTest.MainPage">
    <Grid RowDefinitions="44,44,150,*" RowSpacing="5">
        <StackLayout Grid.Row="0" Orientation="Horizontal">
            <Label Text="Role:" VerticalOptions="Center" />
            <Picker VerticalOptions="Center">
                <Picker.ItemsSource>
                    <x:Array Type="{x:Type x:String}">
                        <x:String>President</x:String>
                        <x:String>Executive Vice President</x:String>
                        <x:String>Executive Managing Director</x:String>
                        <x:String>General Manager</x:String>
                        <x:String>Manager</x:String>
                        <x:String>Section Chief</x:String>
                        <x:String>Deputy Section Manager</x:String>
                        <x:String>Employee</x:String>
                    </x:Array>
                </Picker.ItemsSource>
            </Picker>
        </StackLayout>
        <Button Grid.Row="1" x:Name="btnAdd" Text="Add Employee" BackgroundColor="Blue" TextColor="White" Clicked="btnAdd_Clicked"/>
        <ScrollView Grid.Row="2" x:Name="svEmployeeList" Orientation="Vertical">
            <StackLayout x:Name="slEmployeeList" Orientation="Vertical" BindableLayout.ItemsSource="{Binding Employees}">
                <BindableLayout.ItemTemplate>
                    <DataTemplate>
                        <Grid ColumnDefinitions="80,*" HeightRequest="44">
                            <Label Grid.Column="0" Text="{Binding EmployeeId}" VerticalOptions="Center" />
                            <Entry Grid.Column="1" Text="{Binding EmployeeName}" VerticalOptions="Center" />
                        </Grid>
                    </DataTemplate>
                </BindableLayout.ItemTemplate>
            </StackLayout>
        </ScrollView>
    </Grid>    
</ContentPage>

It is a simple screen layout with Picker, Button, Label and Entry in list format. Pressing the Add Employee button adds a new employee to the employee list and scrolls to the bottom of the employee list.

MainPage's internal logic and various classes are shown below.

[MainPage.xaml.cs]

using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace NavigationPageFocusTest;

public partial class MainPage : ContentPage
{
    /// <summary>
    /// Employee
    /// </summary>
    public class Employee : INotifyPropertyChanged
    {
        /// <summary>
        /// Property Changed Event
        /// </summary>
        public event PropertyChangedEventHandler PropertyChanged;
        /// <summary>
        /// Property Changed 
        /// </summary>
        /// <param name="name">Property Name</param>
        public void OnPropertyChanged([CallerMemberName] string name = "") => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));

        /// <summary>
        /// Employee Id
        /// </summary>
        private int pEmployeeId;
        /// <summary>
        /// Employee Name
        /// </summary>
        private string pEmployeeName;

        /// <summary>
        /// Employee Id
        /// </summary>
        public int EmployeeId
        {
            get 
            { 
                return pEmployeeId; 
            }
            set 
            { 
                if (pEmployeeId != value)
                {
                    pEmployeeId = value;
                    OnPropertyChanged(nameof(EmployeeId));
                }
            }
        }
        /// <summary>
        /// Employee Name
        /// </summary>
        public string EmployeeName
        {
            get
            {
                return pEmployeeName;
            }
            set
            {
                if (pEmployeeName != value)
                {
                    pEmployeeName = value;
                    OnPropertyChanged(nameof(EmployeeName));
                }
            }
        }
    }

    /// <summary>
    /// MainPage View Model
    /// </summary>
    public class ViewModelMainPage : INotifyPropertyChanged
    {
        /// <summary>
        /// Property Changed Event
        /// </summary>
        public event PropertyChangedEventHandler PropertyChanged;
        /// <summary>
        /// Property Changed 
        /// </summary>
        /// <param name="name">Property Name</param>
        public void OnPropertyChanged([CallerMemberName] string name = "") =>
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));

        /// <summary>
        /// Employee List
        /// </summary>
        private List<Employee> pEmployeeList;
    
        /// <summary>
        /// Employee List (Orderd EmployeeId)
        /// </summary>
        public IEnumerable<Employee> Employees
        {
            get
            {
                return pEmployeeList.AsEnumerable()
                    .OrderBy(
                        x => x.EmployeeId
                    );
            }
        }

        /// <summary>
        /// Constructor
        /// </summary>
        public ViewModelMainPage()
        {
            // Create Employee List
            pEmployeeList = new List<Employee>();
        }

        /// <summary>
        /// Add Employee
        /// </summary>
        /// <param name="employeeId">Employee Id</param>
        /// <param name="employeeName">Employee Name</param>
        public void AddEmployee(int employeeId, string employeeName)
        {
            // Add Employee
            pEmployeeList.Add(new Employee() { EmployeeId = employeeId, EmployeeName = employeeName });
            // Update Employee List
            OnPropertyChanged(nameof(Employees));
        }
    }

    /// <summary>
    /// Main Page ViewModel
    /// </summary>
    public ViewModelMainPage vmMainPage = null;

    /// <summary>
    /// Constructor
    /// </summary>
    public MainPage()
    {
        InitializeComponent();

        // Create Main Page View Model
        vmMainPage = new ViewModelMainPage();
        // Add Test Employee
        vmMainPage.AddEmployee(1, "Taro Yamada");
        vmMainPage.AddEmployee(2, "Hanako Yamada");
   
        // Bind MainPage View Model
        this.BindingContext = vmMainPage;
    }

    /// <summary>
    /// Add Employee
    /// </summary>
    /// <param name="sender">Event Source</param>
    /// <param name="e">Event Args</param>
    private void btnAdd_Clicked(object sender, EventArgs e)
    {
        // Set Next Employee Id
        int nextId = vmMainPage.Employees.AsEnumerable().Max(x => x.EmployeeId) + 1;
        // Create New Employee
        vmMainPage.AddEmployee(nextId, null);
    
        // Scroll Employee List
        IDispatcherTimer timer;
        timer = Dispatcher.CreateTimer();
        timer.Interval = new TimeSpan(0, 0, 0, 0, 10);
        timer.Tick += (s, e) =>
        {
            timer.Stop();

            MainThread.BeginInvokeOnMainThread(() =>
            {
                svEmployeeList.ScrollToAsync(slEmployeeList, ScrollToPosition.End, true);
            });
        };
        timer.Start();
    }
}

[App.xaml.cs]

MainPage = new NavigationPage(new MainPage());

Below is a video of the verification. (Verification was performed on Android.)

[When using NavigationPage to generate MainPage (.NET MAUI Version)]

https://user-images.githubusercontent.com/125236133/227823981-dc744e88-3ecb-4b7c-9211-01792c30e802.mp4

To reproduce the issue, the Entry should be tapped to focus before pressing the Add Employee button. After pressing the Add Employee button, the Picker at the top of the screen is focused and the Picker Dialog is opened. I don't want this focus shift. In Xamarin.Forms, such focus movement did not occur.

[When using NavigationPage to generate MainPage (Xamarin.Forms Version)]

https://user-images.githubusercontent.com/125236133/227823630-7f2cddc2-c877-469c-b88c-10eb8b1154b5.mp4

If you change MainPage generation to Shell instead of NavigationPage, this focus movement will not occur.

MainPage = new AppShell();

[When using Shell to generate MainPage (.NET MAUI Version)]

https://user-images.githubusercontent.com/125236133/227825027-4668a61d-fbd8-4825-a3ee-fe2ddc771694.mp4

There is an obvious difference in focus control between NavigationPage and Shell. In Xamarin Forms, NavigationPage was used to generate MainPage, so I migrated to .NET MAUI as it is, but I'm having trouble with unintended focus control.

Do you have any good ideas? Thank you.

cat0363 avatar Mar 27 '23 02:03 cat0363

I have uploaded the code for this issue to github. https://github.com/cat0363/Maui-Issue14211

Since there is no build environment for iOS, operation cannot be verified. Operation verification was performed on Android.

cat0363 avatar Mar 27 '23 02:03 cat0363

We've added this issue to our backlog, and we will work to address it as time and resources allow. If you have any additional information or questions about this issue, please leave a comment. For additional info about issue management, please read our Triage Process.

ghost avatar Mar 27 '23 14:03 ghost

Additional Information:

I found a pattern that this problem also occurs on Shell. By sandwiching the screen transition by Navigation on Shell, the same problem that occurred on NavigationPage will occur.

Below is the code needed to reproduce this issue. https://github.com/cat0363/Maui-Issue14211-2

The same problem occurs when the screen transition is sandwiched by the Navigation.PushAsync method even once on Shell.

https://user-images.githubusercontent.com/125236133/228398686-b9449c21-b6a6-4e91-9c72-e12e262cd681.mp4

cat0363 avatar Mar 29 '23 00:03 cat0363

I've tried looking at focus changes before and after the ViewModel's PropertyChanged event is called. Below is the github source code that I uploaded today.

vmPage2.AddEmployee(nextId, null);  // <= here

[NavigationPage] (No page transition) Before : Microsoft.Maui.Platform.MauiAppCompatEditText After : Microsoft.Maui.Platform.MauiPicker

[Shell] (With page transition) Before : Microsoft.Maui.Platform.MauiAppCompatEditText After : Microsoft.Maui.Platform.MauiPicker

[Shell] (No page transition) Before : Microsoft.Maui.Platform.MauiAppCompatEditText After : AndroidX.RecyclerView.Widget.RecyclerView

In Shell when there is no page transition, the focus is moved to RecyclerView after the PropertyChanged event occurs. In other cases the focus is moved to the Picker.

If the focus is cleared, this problem should not occur. I thought of a workaround, albeit for Android only. Define the following methods in the partial class.

public partial class ServiceFocus
{
    /// <summary>
    /// Clear Focus
    /// </summary>
    public partial void ClearFocus();
}

Implement a partial class on the Android side as follows.

public partial class ServiceFocus
{
    /// <summary>
    /// Clear Focus
    /// </summary>
    public partial void ClearFocus()
    {
        Android.Views.View view = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity.CurrentFocus;
        if (view != null && view is EditText)
        {
            view.ClearFocus();
        }
    }
}

Call the following at the beginning of the Clicked event of the Add Employee button.

new ServiceFocus().ClearFocus();

[NavigationPage] (No page transition)

https://user-images.githubusercontent.com/125236133/229020379-24fef04d-8768-4467-8df5-2990af927d51.mp4

[Shell] (With page transition)

https://user-images.githubusercontent.com/125236133/229020392-9e31d43f-c148-4c77-898d-fe45ab4f987d.mp4

[Shell] (No page transition)

https://user-images.githubusercontent.com/125236133/229020792-e1678856-fb12-4e65-bc5b-4adfa178ed1a.mp4

This problem can be avoided by clearing the focus because the focus movement is a problem after the ViewModel property is changed, but it is troublesome to write this on a case-by-case basis.

I've uploaded a workaround for this issue below. https://github.com/cat0363/Maui-Workaround14211

All patterns can be verified by rewriting the github source code. Since there is no build environment, the implementation on the iOS side is empty.

cat0363 avatar Mar 31 '23 04:03 cat0363

Additional Information: I also verified on the iOS side. This issue does not occur on the iOS side. Occurs only on Android.

cat0363 avatar Jun 29 '23 02:06 cat0363

Verified this issue with Visual Studio Enterprise 17.7.0 Preview 6.0. Can repro on android platform with sample project. Maui-Issue14211-main.zip Screenshot 2023-08-09 153937

Zhanglirong-Winnie avatar Aug 09 '23 07:08 Zhanglirong-Winnie

#14692 seems related

MV10 avatar Aug 27 '23 17:08 MV10

Is #19733 possibly related to this bug? #19733 shows a very simple repro so it might be actually easy to fix.

MartyIX avatar Jan 07 '24 12:01 MartyIX

https://github.com/microsoft/microsoft-ui-xaml/issues/597 was marked as completed in WinAppSDK 1.6.

Once the SDK is released (it's not at the moment AFAIK), it can be tested if it fixes this bug.

MartyIX avatar Feb 24 '24 18:02 MartyIX