Xamarin Forms - iOS Safe Area Layout Guide

Ridin' into the safe zone

I love Xamarin Forms as a cross platform development tool, allowing .NET developers to bring their skills into the mobile world. One of the biggest fumbles I see new comers make is assuming that Xamarin will completely abstract the native platforms. Unfortunately, even the best abstractions are leaky and sometimes you just have to dip into the native worlds of Android and iOS.

In iOS 11 the Safe Area Layout Guide was introduced; a new API describing the safe area of the screen to render content. This prevents view clipping by the notch, virtual home button and rounded screen corners of newer iPhone devices (X & 11 Pro).

The Safe Area Layout Guide is platform specific to iOS, it doesn’t exist on Android, but if you don’t account for it in your designs it could mean a lot of unwanted and tedious refactoring in later stages of a project.

At it’s worst it looks like this,

No safe area

The page header and footer are obscured by the iPhone’s UI elements. Xamarin Forms does provide a platform configuration option, which will apply the appropriate padding to our page. However, this is a generic solution and for instances where you might want a fancy gradient background (provided by PancakeView) you’ll end up with something like this,

Safe area provided by Xamarin Forms

The white bars are from the background colour of the page peeking through after the padding has been applied. For more complex UI’s we need an ad-hoc way to apply the Safe Area Layout Guide per view.

My solution is an effect that can be added to any view that supports the SizeChanged event handler (to detect orientation changes) and the Padding property.

In the cross platform project we add our RoutingEffect,

using System;
using System.Linq;
using Xamarin.Forms;

namespace SafeZone.Effects
{
    [Flags]
    public enum SafeAreaInsets
    {
        None = 0,
        Left = 1,
        Top = 2 << 0,
        Right = 2 << 1,
        Bottom = 2 << 2,
        All = 2 << 3,
    }

    public class SafeAreaInsetEffect : RoutingEffect
    {
        public SafeAreaInsetEffect() : base($"SafeZone.Effects.{nameof(SafeAreaInsetEffect)}")
        {
        }

        public static readonly BindableProperty InsetsProperty =
            BindableProperty.CreateAttached(
                "Insets",
                typeof(SafeAreaInsets),
                typeof(SafeAreaInsetEffect),
                SafeAreaInsets.None,
                propertyChanged: OnInsetsChanged);

        public static SafeAreaInsets GetInsets(BindableObject view)
        {
            return (SafeAreaInsets)view.GetValue(InsetsProperty);
        }

        public static void SetInsets(BindableObject view, SafeAreaInsets value)
        {
            view.SetValue(InsetsProperty, value);
        }

        private static void OnInsetsChanged(BindableObject bindable, object oldValue, object newValue)
        {
            if (bindable is VisualElement element)
            {
                // Automatically add the effect to the view once the property is attached
                var toRemove = element.Effects.FirstOrDefault(e => e is SafeAreaInsetEffect);
                if (toRemove != null)
                {
                    element.Effects.Remove(toRemove);
                }

                var insets = newValue as SafeAreaInsets?;
                if (insets.HasValue)
                {
                    element.Effects.Add(new SafeAreaInsetEffect());
                }
            }
        }
    }
}

Next we add an implementation for our effect in iOS,

using SafeZone.Effects;
using SafeZone.iOS.Effects;
using Foundation;
using UIKit;
using Xamarin.Forms;
using Xamarin.Forms.Platform.iOS;
using System.Reflection;
using System;

[assembly: ResolutionGroupName("SafeZone.Effects")]
[assembly: ExportEffect(typeof(SafeAreaInsetEffect_iOS), nameof(SafeAreaInsetEffect))]
namespace SafeZone.iOS.Effects
{
    [Preserve(AllMembers = true)]
    public class SafeAreaInsetEffect_iOS : PlatformEffect
    {
        private PaddingElement _paddingElement;
        private Thickness _originalPadding;
        private bool _registered;

        protected override void OnAttached()
        {
            _paddingElement = new PaddingElement(Element);

            _originalPadding = _paddingElement.Padding;
            ApplyInsets(_paddingElement, _originalPadding);

            _paddingElement.Element.SizeChanged += ElementSizeChanged;
            _registered = true;
        }

        protected override void OnDetached()
        {
            if (_registered)
            {
                _paddingElement.Element.SizeChanged -= ElementSizeChanged;
                _paddingElement.Padding = _originalPadding;
                _registered = false;
            }
        }

        private void ElementSizeChanged(object sender, System.EventArgs e)
        {
            // Handle orientation changes
            if (_registered)
            {
                ApplyInsets(_paddingElement, _originalPadding);
            }
        }

        private static void ApplyInsets(PaddingElement paddingElement, Thickness defaultPadding)
        {
            var safeInset = GetSafeAreaInset();

            // Get the attached property value so we can apply the appropriate padding
            var insetFlags = SafeAreaInsetEffect.GetInsets(paddingElement.Element);

            // Combine the safe inset with the view's current padding
            var newPadding = CombineInset(defaultPadding, safeInset, insetFlags);

            paddingElement.Padding = newPadding;
        }

        private static Thickness GetSafeAreaInset()
        {
            var edgeInsets = default(UIEdgeInsets);

            if (UIDevice.CurrentDevice.CheckSystemVersion(11, 0))
            {
                edgeInsets = UIApplication.SharedApplication.Windows[0].SafeAreaInsets;
            }
            else
            {
                switch (UIApplication.SharedApplication.StatusBarOrientation)
                {
                    case UIInterfaceOrientation.Portrait:
                    case UIInterfaceOrientation.PortraitUpsideDown:
                        // Default padding for older iPhones (top status bar)
                        edgeInsets = new UIEdgeInsets(20, 0, 0, 0);
                        break;
                    default:
                        edgeInsets = new UIEdgeInsets(0, 0, 0, 0);
                        break;
                }
            }

            return new Thickness(edgeInsets.Left, edgeInsets.Top, edgeInsets.Right, edgeInsets.Bottom);
        }

        private static Thickness CombineInset(Thickness defaultPadding, Thickness safeInset, SafeAreaInsets safeAreaInsets)
        {
            var result = new Thickness(defaultPadding.Left, defaultPadding.Top, defaultPadding.Right, defaultPadding.Bottom);

            if (safeAreaInsets != SafeAreaInsets.None)
            {
                if (safeAreaInsets.HasFlag(SafeAreaInsets.All))
                {
                    result.Left += safeInset.Left;
                    result.Top += safeInset.Top;
                    result.Right += safeInset.Right;
                    result.Bottom += safeInset.Bottom;
                }
                else
                {
                    if (safeAreaInsets.HasFlag(SafeAreaInsets.Left))
                    {
                        result.Left += safeInset.Left;
                    }
                    if (safeAreaInsets.HasFlag(SafeAreaInsets.Top))
                    {
                        result.Top += safeInset.Top;
                    }
                    if (safeAreaInsets.HasFlag(SafeAreaInsets.Right))
                    {
                        result.Right += safeInset.Right;
                    }
                    if (safeAreaInsets.HasFlag(SafeAreaInsets.Bottom))
                    {
                        result.Bottom += safeInset.Bottom;
                    }
                }
            }

            return result;
        }

        private class PaddingElement
        {
            private readonly PropertyInfo _paddingInfo;

            public PaddingElement(object e)
            {
                // VisualElement provides the SizeChanged event handler
                if (e is VisualElement ve)
                {
                    Element = ve;

                    // Make sure we've got a view with Padding support
                    var type = Element.GetType();
                    if (type.GetProperty(nameof(Layout.Padding)) is PropertyInfo pi &&
                        pi.GetMethod != null &&
                        pi.SetMethod != null)
                    {
                        _paddingInfo = pi;
                    }
                    else
                    {
                        throw new ArgumentException($"Element must have a {nameof(Layout.Padding)} property with a public getter & setter!");
                    }
                }
                else
                {
                    throw new ArgumentException($"Element must inherit from {nameof(VisualElement)}");
                }
            }

            public VisualElement Element { get; private set; }

            public Thickness Padding
            {
                get => (Thickness)_paddingInfo.GetValue(Element);
                set => _paddingInfo.SetValue(Element, value);
            }
        }
    }
}

Now we apply our effect to the PancakeView providing the gradient background,

Safe area provided by custom Effect

Full sample available here.

comments powered by Disqus