Monday, April 03, 2017

Displaying a Xamarin.Forms ActionSheet using MVVM

When it comes to mobile applications, there are many different ways to prompt a user for input. Xamarin.Forms has adopted a technique originally introduced in iOS called an ActionSheet that can prompt the user for one or many options.

While Xamarin.iOS provides the UIAlertController to display Popups and ActionSheets, the only way to present the ActionSheet in Xamarin.Forms is through the DisplayActionSheet method on the Page object. If we want to present this dialog to our users using MVVM, accessing the Page object from the code-behind would be violating one of the MVVM best-practices, so we’d need to find another way that’s more “MVVM Friendly”. I’ve had a lot of success with the following approach. Maybe you will, too.

What’s an ActionSheet?

Before we dive into the MVVM component, let’s take a closer look at the ActionSheet. An ActionSheet prompts the user to choose a selection from a list but also provides two specific options: Cancel and Destruction.  The “Cancel” option is obvious, but perhaps the “Destruction” option may not. In iOS, this term represents a destructive operation that is highlighted in red. For example, an actionsheet shown to users during a photo editing session may show a list of file sizes (“Small”, “Medium”, “Large”), a “Cancel” button and a destructive operation, “Discard Changes”.

Options, Options, Options…

To show the ActionSheet using bindings from our ViewModel, we have a few options:

  1. Implement a custom control. We could implement a custom control to house our logic to launch the ActionSheet, but this feels a bit awkward, especially since it wouldn’t actually render anything. We’d also run into issues about where to put the Control in the logical tree.
  2. Custom Behaviour. This seems like the best approach, but the way behaviours are implemented in Xamarin.Forms isn’t exactly how they’re done in WPF, and I discovered first hand a few issues where bindings were being fired multiple times, etc. Which led me to…
  3. Attached Properties. Perhaps the precursor to Behaviors, Attached Properties provide us with a mechanism to store state in the visual elements and then define callbacks for when those values change. This technique isn’t as clean as I’d like but it works really well.

Show me the Codes

As mentioned above, my preferred approach to show the ActionSheet works on this basic principle:

  • Bind some data that we want to show using some Attached Properties,
  • Trigger the ActionSheet when one of the BindableProperty changes,
  • Use the VisualTreeHelper mentioned in my last post to find the Page element,
  • Display the ActionSheet using the Page.DisplayActionSheet method.

We’ll expose the following properties:

  • Parameters: rather than binding all the various display values as separate values, we bundle them up into a single class (see ActionSheetParameters below)
  • IsVisible: the BindableProperty that will be used to trigger the alert.
  • Result: the BindableProperty that will contain the user’s selection.
  • ResultCommand: an alternative to using the Result property if you want to be notified by Command when the user selects a value.

The end result looks like this:


namespace XF.CaliburnMicro1.Controls
{
    using System.Windows.Input;
    using Xamarin.Forms;

    public class ActionSheet
    {
        #region Parameters
        public static BindableProperty ParametersProperty =
            BindableProperty.CreateAttached(
                "Parameters", 
                typeof(ActionSheetParameters), 
                typeof(ActionSheet), 
                default(ActionSheetParameters));

        public static ActionSheetParameters GetParameters(BindableObject bindable)
        {
            return (ActionSheetParameters)bindable.GetValue(ParametersProperty);
        }

        public static void SetParameters(BindableObject bindable, ActionSheetParameters value)
        {
            bindable.SetValue(ParametersProperty, value);
        }
        #endregion

        #region Result
        public static BindableProperty ResultProperty =
            BindableProperty.CreateAttached(
                "Result", 
                typeof(string), 
                typeof(ActionSheet), 
                default(string), 
                defaultBindingMode: BindingMode.TwoWay);

        public static string GetResult(BindableObject bindable)
        {
            return (string)bindable.GetValue(ResultProperty);
        }

        public static void SetResult(BindableObject bindable, string value)
        {
            bindable.SetValue(ResultProperty, value);
        }
        #endregion

        #region Command
        public static BindableProperty CommandProperty =
            BindableProperty.CreateAttached(
                "Command",
                typeof(ICommand),
                typeof(ActionSheet),
                null);

        public static ICommand GetCommand(BindableObject bindable)
        {
            return (ICommand)bindable.GetValue(CommandProperty);
        }

        public static void SetCommand(BindableObject bindable, string value)
        {
            bindable.SetValue(CommandProperty, value);
        } 
        #endregion

        #region IsVisible
        public static BindableProperty IsVisibleProperty =
           BindableProperty.CreateAttached(
               "IsVisible", 
               typeof(bool), 
               typeof(ActionSheet), 
               default(bool), 
               propertyChanged: OnShowDialog, 
               defaultBindingMode: BindingMode.TwoWay);

        public static bool GetIsVisible(BindableObject bindable)
        {
            return (bool)bindable.GetValue(IsVisibleProperty);
        }

        public static void SetIsVisible(BindableObject bindable, bool value)
        {
            bindable.SetValue(IsVisibleProperty, value);
        }
        #endregion

        private static async void OnShowDialog(BindableObject bindable, object oldValue, object newValue)
        {
            bool showAlert = (bool)newValue;
            if (showAlert)
            {
                var page = VisualTreeHelper.GetParent<Page>((Element)bindable);

                ActionSheetParameters args = GetParameters(bindable);
                
                if (page != null && args != null)
                {
                    string result = await page.DisplayActionSheet(args.Title, args.Cancel, args.Destruction, args.Buttons);

                    SetResult(bindable, result); // pass result back to binding

                    // pas result back to viewmodel using command
                    ICommand command = GetCommand(bindable);
                    if (result != null && command != null)
                    {
                        command.Execute(result);
                    }

                    SetIsVisible(bindable, false); // reset the dialog
                }
            }
        }
    }

    public class ActionSheetParameters
    {
        /// <summary>
        /// Action sheet title
        /// </summary>
        public string Title { get; set; }

        /// <summary>
        /// Cancel button title
        /// </summary>
        public string Cancel { get; set; }

        /// <summary>
        /// Destructive action title
        /// </summary>
        public string Destruction { get; set; }

        /// <summary>
        /// List of Buttons
        /// </summary>
        public string[] Buttons { get; set; }
    }
}

From the above, the work is done when the IsVisible property changes. Our ViewModel would look like this:

public class ExampleViewModel : Screen
{
    private bool _showDialog;
    private string _result;
    private ActionSheetParameters _parameters;

    public ExampleViewModel()
    {
        ShowDialogCommand = new DelegateCommand((o) =>
        {
            DialogParameters = new ActionSheetParameters
            {
                Title = "Choose your option wisely",
                Cancel = "Cancel",
                Destruction = "Self Destruct",
                Buttons = new[] { "One", "Two", "Red", "Blue" }
            };

            ShowDialog = true;
        });
    }

    public bool ShowDialog
    {
        get { return _showDialog; }
        set { SetField(ref _showDialog, value); }
    }

    public string Result
    {
        get { return _result; }
        set { SetField(ref _result, value); }
    }

    public ActionSheetParameters DialogParameters
    {
        get { return _parameters; }
        set { SetField(ref _parameters, value); }
    }

    public DelegateCommand ShowDialogCommand
    {
        get;
        protected set;
    }

    protected void SetField<T>(ref T field, T value, [CallerMemberName] string propertyName = null)
    {
        if (!Object.Equals(field, value))
        {
            field = value;
            NotifyOfPropertyChange(propertyName);
        }
    }
}

And the corresponding View:

<?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:controls="clr-namespace:XF.CaliburnMicro1.Controls"
             x:Class="XF.CaliburnMicro1.Views.Tab2View"
             
             controls:ActionSheet.Parameters="{Binding DialogParameters}"
             controls:ActionSheet.Result="{Binding Result}"
             controls:ActionSheet.IsVisible="{Binding ShowDialog}"
             >

    <StackLayout>
        <Button Text="Show Dialog" Command="{Binding ShowDialogCommand}" />
        <Label Text="{Binding Result}" />
    </StackLayout>    
    
</ContentView>

This technique works well for both iOS and Android.

Happy coding.

submit to reddit

0 comments: