Adding Tactile Feedback to your app the easy way

About a week ago there was a very good article with tips to make your wp7 app a killer app. Tip 24 was about tactile feedback. Tactile feedback can be something like 30 milliseconds running the VibrateController. That’s very easy, just like this:

VibrateController vibrateController = VibrateController.Default;
vibrateController.Start(new TimeSpan(0, 0, 0, 0, 30));

Because I just want to call one method instead of two lines of code, I created a small static method.

public static void GiveTactileFeedback()
{
    VibrateController vibrateController = VibrateController.Default;
    vibrateController.Start(new TimeSpan(0, 0, 0, 0, 30));
}

Alright, but now I had to add this method call to every Button Click, ListBox SelectionChanged, and ApplicationBar menu or button use. Probably there are some more places where I want to give a little bit of feedback. So I’m thinking about a solution, a generic solution. I remember how easy it was to apply the TiltEffect. So my mind was going the direction of a similar solution. So I started with some Attached Dependency Properties, the same mechanism that’s used to implement the TiltEffect. So enable it for example in the root of your Phone Page and only suppress the effect if you don’t want to apply it a special occasion.

<phone:PhoneApplicationPage ... 
                            WPUI:TactileFeedbackEffect.IsTactileFeedbackEnabled="true">

And suppress it in this way.

<Button Content="Feedback surpressed"
        WPUI:TactileFeedbackEffect.SuppressTactileFeedback="true" />

Basically I want to walk through all the children of the element that have the IsTactileFeedbackEnabled property set to true. So I started walking recursively through the Visual Tree.

public static IEnumerable<FrameworkElement> GetSelfAndChildren(this FrameworkElement node)
{
    var elements = new List<FrameworkElement>();
    int childrenCount = VisualTreeHelper.GetChildrenCount(node);
    elements.Add(node);
    for (int i = 0; i < childrenCount; i++)
    {
        var childFe = VisualTreeHelper.GetChild(node, i) as FrameworkElement;
        if (childFe != null)
        {
            elements.AddRange(GetSelfAndChildren(childFe));
        }
    }
    return elements;
}

Using this Visual Tree of elements to check against controls that should support the Tactile Feedback: ButtonBase (root of all buttons), Selector (root of the different types of ListBoxes), PhoneApplicationPage (only way to get a hold of the  ApplicationBar’s buttons and menu items). For the menu items and buttons, it’s just simply attaching to the Click event. The Selector has a SelectionChanged event to which we can listen. Not that much of rocket science.

The initial idea I had was to make use of the Loaded event of the Element that has  the DependencyProperty applied to start walking though the visual tree. But what happened when the Visual Tree changes? Caused for example by Data Binding changes, or any of the other ways that enable you to manipulate the Visual Tree. So I attached to the LayoutUpdated event as well, I had to hack a bit, because the sender in the LayoutUpdated event is always null.

In het end I came to the conclusion that I didn’t have a way to suppress feedback on the ApplicationBar items.This is mainly because the ApplicationBar items are not inheriting from DependencyObject.

Full code is below for both the VisualTreeHelper and the TactileFeedbackEffect. Please let me know if you have any questions or improvements.

/// <summary>
/// Simple helpers for walking the visual tree
/// </summary>
internal static class TreeHelpers
{
    public static IEnumerable<FrameworkElement> GetSelfAndChildren(this FrameworkElement node)
    {
        var elements = new List<FrameworkElement>();
        int childrenCount = VisualTreeHelper.GetChildrenCount(node);
        elements.Add(node);
        for (int i = 0; i < childrenCount; i++)
        {
            var childFe = VisualTreeHelper.GetChild(node, i) as FrameworkElement;
            if (childFe != null)
            {
                elements.AddRange(GetSelfAndChildren(childFe));
            }
        }
        return elements;
    }
}

public class TactileFeedbackEffect : DependencyObject
{
    /// <summary>
    /// Set this Attached Dependency Property to true to enable tactile feedback on an element and it's children.
    /// </summary>
    public static readonly DependencyProperty IsTactileFeedbackEnabledProperty = DependencyProperty.RegisterAttached
        (
            "IsTactileFeedbackEnabled",
            typeof (bool),
            typeof (TactileFeedbackEffect),
            new PropertyMetadata(OnIsTactileFeedbackEnabledChanged)
        );

    /// <summary>
    /// Set this Attached Dependency Property to true to suppress tactile feedback on the element.
    /// </summary>
    public static readonly DependencyProperty SuppressTactileFeedbackProperty = DependencyProperty.RegisterAttached(
        "SuppressTactileFeedback",
        typeof (bool),
        typeof (TactileFeedbackEffect),
        new PropertyMetadata(false));

    static TactileFeedbackEffect()
    {
        TactileFeedbackItems = new List<Type>
                                    {typeof (ButtonBase), typeof (PhoneApplicationPage), typeof (Selector)};
    }

    private TactileFeedbackEffect()
    {
    }

    public static List<Type> TactileFeedbackItems { get; private set; }

    public static bool GetIsTactileFeedbackEnabled(DependencyObject source)
    {
        return (bool) source.GetValue(IsTactileFeedbackEnabledProperty);
    }


    public static void SetIsTactileFeedbackEnabled(DependencyObject source, bool value)
    {
        source.SetValue(IsTactileFeedbackEnabledProperty, value);
    }

    public static bool GetSuppressTactileFeedback(DependencyObject source)
    {
        return (bool) source.GetValue(SuppressTactileFeedbackProperty);
    }


    public static void SetSuppressTactileFeedback(DependencyObject source, bool value)
    {
        source.SetValue(SuppressTactileFeedbackProperty, value);
    }

    private static void OnIsTactileFeedbackEnabledChanged(DependencyObject target,
                                                            DependencyPropertyChangedEventArgs args)
    {
        if (target is FrameworkElement)
        {
            if ((bool) args.NewValue)
            {
                (target as FrameworkElement).Loaded += (s, e) =>
                                                            {
                                                                var senderDo = s as DependencyObject;
                                                                TryAttachFeedback(senderDo);
                                                                if (senderDo is FrameworkElement)
                                                                    (senderDo as FrameworkElement).LayoutUpdated +=
                                                                        (s2, e2) =>
                                                                            {
                                                                                TryDetachFeedback(senderDo);
                                                                                TryAttachFeedback(senderDo);
                                                                            };
                                                            };
            }
            else
            {
                TryDetachFeedback(target);
            }
        }
    }

    private static void TryAttachFeedback(DependencyObject target)
    {
        foreach (FrameworkElement element in (target as FrameworkElement).GetSelfAndChildren())
        {
            foreach (Type t in TactileFeedbackItems)
            {
                if (t.IsAssignableFrom(element.GetType()))
                {
                    if ((bool) element.GetValue(SuppressTactileFeedbackProperty) != true)
                    {
                        if (element is ButtonBase)
                        {
                            (element as ButtonBase).Click += TactileFeedbackEffectClick;
                        }
                        if (element is PhoneApplicationPage)
                        {
                            var page = element as PhoneApplicationPage;
                            if (page.ApplicationBar != null)
                            {
                                foreach (IApplicationBarMenuItem button in
                                    page.ApplicationBar.Buttons ?? new List<IApplicationBarMenuItem>())
                                {
                                    button.Click += TactileFeedbackEffectApplicationBarClick;
                                }
                                foreach (IApplicationBarMenuItem menuItem in
                                    page.ApplicationBar.MenuItems ?? new List<IApplicationBarMenuItem>())
                                {
                                    menuItem.Click += TactileFeedbackEffectApplicationBarClick;
                                }
                            }
                        }
                        if (element is Selector)
                        {
                            (element as Selector).SelectionChanged += TactileFeedbackEffectSelectionChanged;
                        }
                    }
                }
            }
        }
    }

    private static void TryDetachFeedback(DependencyObject target)
    {
        foreach (FrameworkElement element in (target as FrameworkElement).GetSelfAndChildren())
        {
            foreach (Type t in TactileFeedbackItems)
            {
                if (t.IsAssignableFrom(element.GetType()))
                {
                    if ((bool) element.GetValue(SuppressTactileFeedbackProperty) != true)
                    {
                        if (element is ButtonBase)
                        {
                            (element as ButtonBase).Click -= TactileFeedbackEffectClick;
                        }
                        if (element is PhoneApplicationPage)
                        {
                            var page = element as PhoneApplicationPage;
                            if (page.ApplicationBar != null)
                            {
                                foreach (IApplicationBarMenuItem button in
                                    page.ApplicationBar.Buttons ?? new List<IApplicationBarMenuItem>())
                                {
                                    button.Click -= TactileFeedbackEffectApplicationBarClick;
                                }
                                foreach (IApplicationBarMenuItem menuItem in
                                    page.ApplicationBar.MenuItems ?? new List<IApplicationBarMenuItem>())
                                {
                                    menuItem.Click -= TactileFeedbackEffectApplicationBarClick;
                                }
                            }
                        }
                        if (element is Selector)
                        {
                            (element as Selector).SelectionChanged -= TactileFeedbackEffectSelectionChanged;
                        }
                    }
                }
            }
        }
    }

    private static void TactileFeedbackEffectSelectionChanged(object sender, SelectionChangedEventArgs e)
    {
        GiveTactileFeedback();
    }

    private static void TactileFeedbackEffectApplicationBarClick(object sender, EventArgs e)
    {
        GiveTactileFeedback();
    }

    private static void TactileFeedbackEffectClick(object sender, RoutedEventArgs e)
    {
        GiveTactileFeedback();
    }

    public static void GiveTactileFeedback()
    {
        VibrateController vibrateController = VibrateController.Default;
        vibrateController.Start(new TimeSpan(0, 0, 0, 0, 30));
    }
}

Gravatar