18 Aralık 2011 Pazar

WPF Busy Indicator

I wanted to create a spinning busy indicator like the one consistently used accross the iPhone UI.  Inspired by this blog, I accomplished this by spinning a png image, like the one below, using the WPF rotatetransform and storyboard.
 
I created a user control called BusyIndicator that uses a white color version of this image in order to show it on a darker UI surface.  BusyIndicator also has Text dependency property that can optionally display a description for the work being done.  Here's how it looks on a login screen (which happens to be in Turkish). 


The user control is almost entirely written in XAML.  I wrote C# code only for the TextToVisibility converter, which is used for the binding that binds the text label's visibility to length > 0 condition of the Text property, and for defining the Text dependency property of course.

XAML:
<UserControl
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:local="clr-namespace:UIPack" mc:Ignorable="d"
    x:Class="UIPack.BusyIndicatorControl"
    x:Name="UserControl" IsEnabled="False">
    <UserControl.Resources>
       
        <local:TextToVisibilityConverter x:Key="TextToVisibilityConverter"/>
       
        <Style x:Key="BusyImage" TargetType="{x:Type Image}">
            <Setter Property="RenderTransformOrigin" Value="0.5,0.5"/>
            <Setter Property="RenderTransform">
                <Setter.Value>
                    <RotateTransform Angle="0"/>
                </Setter.Value>
            </Setter>
            <Style.Triggers>
                <Trigger Property="IsEnabled" Value="true">
                    <Trigger.EnterActions>
                        <BeginStoryboard>
                            <Storyboard Timeline.DesiredFrameRate="12">
                                <DoubleAnimation
                                    Storyboard.TargetProperty="RenderTransform.Angle"
                                    From="0" To="360" Duration="0:0:1"
                                    RepeatBehavior="Forever" />
                            </Storyboard>
                        </BeginStoryboard>
                    </Trigger.EnterActions>
                </Trigger>
            </Style.Triggers>
        </Style>
       
        <Style x:Key="MyStackPanelStyle" TargetType="{x:Type StackPanel}">
            <Style.Resources>
                <Storyboard x:Key="Show">
                    <ObjectAnimationUsingKeyFrames
                        Storyboard.TargetProperty="(UIElement.Visibility)"
                        Storyboard.TargetName="{x:Null}">
                        <DiscreteObjectKeyFrame KeyTime="0:0:0.3"
                            Value="{x:Static Visibility.Visible}"/>
                    </ObjectAnimationUsingKeyFrames>
                </Storyboard>
                <Storyboard x:Key="Hide">
                    <ObjectAnimationUsingKeyFrames
                        Storyboard.TargetProperty="(UIElement.Visibility)"
                        Storyboard.TargetName="{x:Null}">
                        <DiscreteObjectKeyFrame KeyTime="0:0:0"
                            Value="{x:Static Visibility.Collapsed}"/>
                    </ObjectAnimationUsingKeyFrames>
                </Storyboard>
            </Style.Resources>
            <Style.Triggers>
                <Trigger Property="IsEnabled" Value="True">
                    <Trigger.ExitActions>
                        <BeginStoryboard Storyboard="{StaticResource Hide}"/>
                    </Trigger.ExitActions>
                    <Trigger.EnterActions>
                        <BeginStoryboard Storyboard="{StaticResource Show}"/>
                    </Trigger.EnterActions>
                </Trigger>
            </Style.Triggers>
        </Style>
       
    </UserControl.Resources>

    <Grid x:Name="LayoutRoot">
        <StackPanel Orientation="Horizontal"
            IsEnabled="{Binding IsEnabled, ElementName=UserControl}"
            Style="{DynamicResource MyStackPanelStyle}"
            Visibility="Collapsed">
            <Image Source="busy.png"
                Style="{DynamicResource BusyImage}"
                MaxWidth="50" MaxHeight="50"/>
            <TextBlock x:Name="textBlock" TextWrapping="Wrap" Margin="5,0,0,0"
                Foreground="{Binding Foreground, ElementName=UserControl, Mode=OneWay}"
                Text="{Binding Text, ElementName=UserControl, Mode=OneWay}"
                Visibility="{Binding Text,
                            Converter={StaticResource TextToVisibilityConverter},
                            ElementName=UserControl, Mode=OneWay}"/>
        </StackPanel>
    </Grid>
   
</UserControl>

C# code:
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;

namespace UIPack
{
    /// <summary>
    /// Interaction logic for BusyIndicatorControl.xaml
    /// </summary>
    public partial class BusyIndicatorControl : UserControl
    {
        public static readonly DependencyProperty TextProperty = DependencyProperty.Register("Text",
                    typeof(string), typeof(BusyIndicatorControl),new FrameworkPropertyMetadata(new PropertyChangedCallback(TextChangedCallback)));


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

        private static void TextChangedCallback(DependencyObject o, DependencyPropertyChangedEventArgs e)
        {
            var busyIndicator = o as BusyIndicatorControl;
            busyIndicator.textBlock.Text = (string)e.NewValue;
        }

        public BusyIndicatorControl()
        {
            this.InitializeComponent();
        }
    }

    public class TextToVisibilityConverter : IValueConverter
    {
        #region IValueConverter Members

        public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
        {
            if (value == null || ((string)value).Trim().Length == 0)
                return Visibility.Collapsed;
            else
                return Visibility.Visible;
        }

        public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
        {
            throw new NotImplementedException(Properties.Resources.NotImplemented);
        }

        #endregion
    }
}

Things to note:
  • Simply spinning the image doesn't give the iPhone like experience. If you look at the iPhone busy indicator, the 12 tick marks on the image always appear at the top-of-the-hour clock positions and never appear in-between.  To give this experience, you'll notice I reduced the WPF storyboard frame rate to 12 fps (Timeline.DesiredFrameRate="12"), which is 60 fps by default.  With the storyboard duration of 1s, this causes each frame of the animation to rotate the image just enough so that the tick marks "jump" to the next clock position.  You can try Timeline.DesiredFrameRate = 60 to see the default behavior.  
  • I used a 50x50 image that scales to the size of the user control.  If you need to use this user control in small sizes (say, height less than 15), you might consider using an image with 6 tick marks instead of 12, and reducing the frame rate to 6 fps. 
  • I show/hide this user control by enabling and disabling it.  Notice that the inner stackpanel has a trigger for becoming visible after a 300 ms delay.  I did this because I only want to show the busy indicator for "long" waits.  I decided; if the action takes 300 ms, it is likely that this is an action that can take a while and we should show a busy indicator.  However, if the action only takes say 10 ms, the busy indicator would merely flash on the UI, which doesn't look good.  For example, in my example above, the user login only takes a few milliseconds *most of the time*, and we don't need to disrupt the UI experience with the busy indicator. In such a short period of time, the human brain won't even comprehend the pause anyway.  However, if there is a network problem, the client might try and fail to connect to the server.  While trying to connect, we should show the busy indicator. 

Hiç yorum yok:

Yorum Gönder