Ever wonder where to place activity indicators in your mobile apps? Quite often, I find myself placing the indicator right on the button itself. One of the benefits of such a design, is that you don't have to free up any screen real estate to show the indicator. Here is an example of what I am talking about:

2019-02-06-18-29-59.2019-02-06-18_32_19-1

Personally, I think this is a clean and simple design. So let's see how we can achieve this using just Xamarin.Forms.

Create a custom ContentView

  1. In the .xaml file, add the following:
<ContentView
    xmlns="http://xamarin.com/schemas/2014/forms"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    x:Class="ActivityButton.Controls.ActivityButton">
    <ContentView.Content>
        <Grid
            HorizontalOptions="FillAndExpand"
            VerticalOptions="FillAndExpand">
            <Button
                x:Name="InnerButton"
                Text="{Binding Text}"
                Command="{Binding Command}"
                CommandParameter="{Binding CommandParameter}"
                HorizontalOptions="FillAndExpand"
                VerticalOptions="FillAndExpand"
                BackgroundColor="{Binding BackgroundColor}"
                TextColor="{Binding TextColor}"
                BorderColor="Transparent"
                Margin="0" />
            
            <ActivityIndicator
                x:Name="InnerActivityIndicator"
                VerticalOptions="Center"
                HorizontalOptions="Center"
                Color="White"
                Scale="{OnPlatform Default=1, Android=0.7}"
                Opacity="0"
                IsRunning="{Binding IsBusy}"
                IsVisible="{Binding IsBusy}"
                IsEnabled="{Binding IsBusy}" />
        </Grid>
    </ContentView.Content>
</ContentView>

There are a couple of things worth pointing out about this layout.

  • As you might expect, there are two components that make up the ActivityButton. We are able to use an ActivityIndicator and a Button in combination to achieve this look.
  • We use a Grid to display these elements on top of each other.
    • Grids can sometimes be a good replacement for AbsoluteLayouts when you want to place certain things in front of one another. To do so, we just omit assiging a row or column to each element.
  • We are able to use bindings to set BindableProperties on the ActivityButton. For instance, we will have a property in the code behind that allows us to tell the ActivityIndicator when to start running.
  1. In the code behind add the following BindablePropertys:
public static readonly BindableProperty TextProperty = BindableProperty.Create(nameof(Text), typeof(string), typeof(ActivityButton), propertyChanged: TextUpdated);
public static readonly BindableProperty CommandProperty = BindableProperty.Create(nameof(Command), typeof(ICommand), typeof(ActivityButton), propertyChanged: CommandUpdated);
public static readonly BindableProperty CommandParameterProperty = BindableProperty.Create(nameof(CommandParameter), typeof(object), typeof(ActivityButton), propertyChanged: CommandParameterUpdated);
public static readonly BindableProperty IsBusyProperty = BindableProperty.Create(nameof(IsBusy), typeof(bool), typeof(ActivityButton), propertyChanged: IsBusyUpdated);
public static readonly BindableProperty CornerRadiusProperty = BindableProperty.Create(nameof(CornerRadius), typeof(int), typeof(ActivityButton), propertyChanged: CornerRadiusUpdated);
public static readonly BindableProperty TextColorProperty = BindableProperty.Create(nameof(TextColor), typeof(Color), typeof(ActivityButton));

public static new readonly BindableProperty BackgroundColorProperty = BindableProperty.Create(nameof(BackgroundColor), typeof(Color), typeof(ActivityButton));

Since we are using a ContentView to combine our two elements, we will have to add a little bit of boiler plate code to make our ActivityButton reusable. For example, we add a TextProperty, CommandProperty, and so on. Also take note, that we are using the new keyword when declaring the BackgroundColorProperty. This is to override the background color of the ContentView. For example, we don't ever want to set the ContentView's background color, but instead the Button's.

  1. Add the following propertyChanged method for the TextProperty:
private static void TextUpdated(object sender, object oldValue, object newValue)
{
    if (sender is ActivityButton activityButton && newValue is string newString)
    {
        activityButton.buttonText = newString;
        activityButton.InnerButton.Text = newString;
    }
}

In the above code, we are setting the Button's text to the new value, but also setting a local variable with the new value.

  1. Use the following code to respond to the bindable value for the IsBusyProperty update.
private static async void IsBusyUpdated(object sender, object oldValue, object newValue)
{
    if (sender is ActivityButton activityButton && newValue is bool newBool)
    {
        activityButton.InnerButton.IsEnabled = !newBool;
        activityButton.InnerActivityIndicator.IsRunning = newBool;
        activityButton.InnerActivityIndicator.IsEnabled = newBool;
        activityButton.InnerActivityIndicator.IsVisible = newBool;

        var opacity = newBool ? 1 : 0;
        await activityButton.InnerActivityIndicator.FadeTo(opacity, 200);

        activityButton.InnerButton.Text = newBool ? string.Empty : activityButton.buttonText;
    }
}

In the above method, we will enable or disable the button, fade the activity indicator in or out, and also set the button text to a value or null. This is really the meat and potatos of what makes this a custom control.

All said and done, your code behind should look something like this:

using System;
using System.Windows.Input;
using Xamarin.Forms;

namespace ActivityButton.Controls
{
    public partial class ActivityButton : ContentView
    {
        private string buttonText;

        public event EventHandler Clicked = delegate { };

        public static readonly BindableProperty TextProperty = BindableProperty.Create(nameof(Text), typeof(string), typeof(ActivityButton), propertyChanged: TextUpdated);
        public static readonly BindableProperty CommandProperty = BindableProperty.Create(nameof(Command), typeof(ICommand), typeof(ActivityButton), propertyChanged: CommandUpdated);
        public static readonly BindableProperty CommandParameterProperty = BindableProperty.Create(nameof(CommandParameter), typeof(object), typeof(ActivityButton), propertyChanged: CommandParameterUpdated);
        public static readonly BindableProperty IsBusyProperty = BindableProperty.Create(nameof(IsBusy), typeof(bool), typeof(ActivityButton), propertyChanged: IsBusyUpdated);
        public static readonly BindableProperty CornerRadiusProperty = BindableProperty.Create(nameof(CornerRadius), typeof(int), typeof(ActivityButton), propertyChanged: CornerRadiusUpdated);
        public static readonly BindableProperty TextColorProperty = BindableProperty.Create(nameof(TextColor), typeof(Color), typeof(ActivityButton));

        public static new readonly BindableProperty BackgroundColorProperty = BindableProperty.Create(nameof(BackgroundColor), typeof(Color), typeof(ActivityButton));

        public ActivityButton()
        {
            this.InitializeComponent();
            this.InnerButton.BindingContext = this;
            this.InnerButton.Clicked += this.OnClicked;
        }

        public string Text
        {
            get => (string)this.GetValue(TextProperty);
            set => this.SetValue(TextProperty, value);
        }

        public ICommand Command
        {
            get => (ICommand)this.GetValue(CommandProperty);
            set => this.SetValue(CommandProperty, value);
        }

        public object CommandParameter
        {
            get => this.GetValue(CommandParameterProperty);
            set => this.SetValue(CommandParameterProperty, value);
        }

        public bool IsBusy
        {
            get => (bool)this.GetValue(IsBusyProperty);
            set => this.SetValue(IsBusyProperty, value);
        }

        public int CornerRadius
        {
            get => (int)this.GetValue(CornerRadiusProperty);
            set => this.SetValue(CornerRadiusProperty, value);
        }

        public new Color BackgroundColor
        {
            get => (Color)this.GetValue(BackgroundColorProperty);
            set => this.SetValue(BackgroundColorProperty, value);
        }

        public Color TextColor
        {
            get => (Color)this.GetValue(TextColorProperty);
            set => this.SetValue(TextColorProperty, value);
        }

        private void OnClicked(object sender, EventArgs args)
        {
            this.Clicked?.Invoke(sender, args);
        }

        private static void TextUpdated(object sender, object oldValue, object newValue)
        {
            if (sender is ActivityButton activityButton && newValue is string newString)
            {
                activityButton.buttonText = newString;
                activityButton.InnerButton.Text = newString;
            }
        }

        private static void CommandUpdated(object sender, object oldValue, object newValue)
        {
            if (sender is ActivityButton activityButton && newValue is ICommand newCommand)
            {
                activityButton.InnerButton.Command = newCommand;
            }
        }

        private static void CommandParameterUpdated(object sender, object oldValue, object newValue)
        {
            if (sender is ActivityButton activityButton && newValue != null)
            {
                activityButton.InnerButton.CommandParameter = newValue;
            }
        }

        private static async void IsBusyUpdated(object sender, object oldValue, object newValue)
        {
            if (sender is ActivityButton activityButton && newValue is bool newBool)
            {
                activityButton.InnerButton.IsEnabled = !newBool;
                activityButton.InnerActivityIndicator.IsRunning = newBool;
                activityButton.InnerActivityIndicator.IsEnabled = newBool;
                activityButton.InnerActivityIndicator.IsVisible = newBool;

                var opacity = newBool ? 1 : 0;
                await activityButton.InnerActivityIndicator.FadeTo(opacity, 200);

                activityButton.InnerButton.Text = newBool ? string.Empty : activityButton.buttonText;
            }
        }

        private static void CornerRadiusUpdated(object sender, object oldValue, object newValue)
        {
            if (sender is ActivityButton activityButton && newValue is int newInt)
            {
                activityButton.InnerButton.CornerRadius = newInt;
            }
        }
    }
}

Ok, so that should be it. For a working sample, check out my GitHub repo.

In order to keep things short and sweet, I omitted some of the boiler plate code. In case you want me to go through that in more detail, just let me know.

@jtaubensee