July 12, 2019

#XamarinUIJuly Adventures in Modern Previewing

The Xamarin.Forms previewer has come a long way recently but I want a notch.

This article is not about drawing stick figures with BoxViews.

This post is part of #XamarinUIJuly, organised by Steve Thewissen. Today is day 12 with another exciting post coming out every day for the rest of the month.

https://www.thewissen.io/introducing-xamarin-ui-july/

The Xamarin.Forms previewer has come a long way recently. It's much more stable, offers real device sizes and has support custom pages types and  controls. The xaml editor has been bounding forwards at the same time making xaml editing so much easier than it was only a few short years(or even months) ago.

The option to select iPhoneX made me wish the previewer showed the notch. The new support for custom controls made it possible. An embarrassing bug report from a customer about data hidden behind the notch was the kick in the pants to make me do something about it.

This post has three roles:

  • This is your timely reminder to give the previewer another go
  • Exploring the new features in the previewer
  • Explaining how I built a new plugin to preview pages with notches and corner radii to preview in context.

Let's start with a whirlwind tour of our new toys.

Real Device Sizes

The previewer used to only offer "Phone" or "Tablet" for each of Android and iOS. Now you can choose from any of the recent iOS device sizes and a handful of popular Android devices.

Even lil ol' iPhone SE gets invited to this party.

Try Again

The previewer has hard work to do. Its constantly parsing your xaml and rendering it. If your xaml was always perfect the previewer would have things a little easier – but while you're modifying code it's frequently invalid until you finish a statement.

In the past when something went wrong the previewer was dead until you closed and reopened the window. Now there's a nifty little try again button so you can tell the previewer when you've cleaned up your mess.

If the issue was within a custom component, these will be disable but the previewer keeps on running. A tasteful, graceful degradation.

Design Time Data

Design time data has been around since the Silver Light days. It hydrates your previews with the context you need to make good design decisions. Maddy Leger explains it all in this article so I won't go into it too far here.

In short – Add three namespaces to your Page.

xmlns:d="http://xamarin.com/schemas/2014/forms/design"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"

Now you can set any property to have a value in the previewer without messing up your final product. If you have a label with a binding to a property Text="{Binding CustomerName}" you can add d:Text="Douc Redshank".

You can even use it to set ItemSource

<d:ListView.ItemsSource>
    <x:Array Type="{x:Type x:String}">
        <x:String>Monkey</x:String>
        <x:String>Koala</x:String>
        <x:String>Oreo</x:String>
    </x:Array>
</d:ListView.ItemsSource>

Custom Controls

You can now opt in to having your controls rendered in the previewer. If your control is fairly simple you'll want to show it in your previewer. If your doing anything too fancy or if it's repeatedly causing problems you'll want to turn it off. Full docs are available but this one is pretty simple.

Mark your class with the [DesignTimeVisible(true)] attribute and you're up and running.

Change it to false if your control is causing havoc.

Notch me up

If you're playing along at home you can check out all the code for this demo from https://github.com/lachlanwgordon/NotchExperiment. Or add the notch to your own project from Nuget by searching for NotchyFormsPreviewer. The source code and set up instructions are in the library's repo on GitHub https://github.com/lachlanwgordon/Notchy.FormsPreviewer.

Alright, let's get into it, time to add the notch and corner radius to the previewer.

This will need to be implemented separately on each platform using native graphics APIs. Some day we may be able to use Skia to implement this in reusable code but for now the Android previewer can't render Skia controls.

We can implement this using Renderers or Effects. I've experimented with both and settled on Effects for a few reasons.

  • Reliability - Effects render faster and more reliably in the previewer. I don't have an explanation for why but effects are notably easier to work with.
  • Retrofitability. It's quick and easy to add an effect to an existing xaml page. Using a renderer requires you to change the base class in at least 3 places, often more.
  • Extensibilty - If you want to work with a different base class you don't have to mess with weird inheritance trees.

To implement this we need three classes.

Step 1. A RoutingEffect in your.NetStandard library.
This doesn't need any implementation details but does need to call the base constructor and pass in the name of your effect.

It also contains any properties you want to pass from your View code into the platform specifics. In this case I'm passing a PhoneModel to identify the style of notch from a list of presets and a Device to allow custom notch/radius configurations.

public class NotchEffect : RoutingEffect
{
    public NotchEffect() : base($"NotchExperiment.{nameof(NotchEffect)}")
    {
    }

    public PhoneModels Model { get; set; }
    public Device CustomDevice { get; set; } = new Device();
}

Step 2. An iOS implementation im your iOS project to modify the Renderer with platform specific controls. This needs to be decorated with two attributes and implement the PlatformEffect interface. PlatformEffect has two virtual methods that need to be overridden and this is where you do all your work. OnAttached() is the main one where we have all the fun.

[assembly:ResolutionGroupName("NotchExperiment")]
[assembly:ExportEffect(typeof(NotchEffect), nameof(NotchEffect))]
namespace NotchExperiment.iOS
{
    public class NotchEffect : PlatformEffect
    {


        protected override void OnAttached()
        {
            var notchInfo = Element.Effects.FirstOrDefault(x => x is NotchExperiment.NotchEffect) as NotchExperiment.NotchEffect;
            if (notchInfo != null)
            {
                Device device;
                if (notchInfo.Model == PhoneModels.Custom)
                {
                    device = notchInfo.CustomDevice;
                }
                else
                {
                    device = Devices[notchInfo.Model];
                }

                NotchView notch;
                if (DesignMode.IsDesignModeEnabled)
                {
                    notch = new NotchView(new System.Drawing.RectangleF(0, 0, (float)device.ScreenWidth, (float)device.ScreenHeight), device);//Previewer always reports size of an iphone 5
                }
                else
                {
                    notch = new NotchView(new System.Drawing.RectangleF(0, 0, (float)Container.Bounds.Width, (float)Container.Bounds.Height), device);
                }

                notch.Bounds = notch.Frame;

                Container.Add(notch);
            }

        }

        protected override void OnDetached()
        {
        }
    }
}

Step 3. An Android implementation in your Android project to modify the Renderer with platform specific controls. This has all the same requirements as the iOS implementation but works with the native Android graphics APIs instead.

[assembly: ResolutionGroupName("NotchExperiment")]
[assembly: ExportEffect(typeof(NotchEffect), nameof(NotchEffect))]
namespace NotchExperiment.Droid
{
    public class NotchEffect : PlatformEffect
    {
        public LinearLayout NotchLayout { get; private set; }
        public Device Device { get; private set; }
        public Android.Views.View.IOnLayoutChangeListener UpdateLayout { get; private set; }
        NotchExperiment.NotchEffect NotchInfo;

        protected override void OnAttached()
        {
            NotchInfo = Element.Effects.FirstOrDefault(x => x is NotchExperiment.NotchEffect) as NotchExperiment.NotchEffect;

            if (Element == null)
            {
                NotchLayout = null;
            }
            else if (NotchLayout == null)
            {
                InitView();
            }
        }

        protected override void OnDetached()
        {
        }


        void InitView()
        {
            NotchLayout = new LinearLayout(Container.Context);
            NotchLayout.LayoutParameters = new Android.Views.ViewGroup.LayoutParams(Android.Views.ViewGroup.LayoutParams.MatchParent, Android.Views.ViewGroup.LayoutParams.MatchParent);


            var button = new Android.Widget.Button(Container.Context);
            button.Text = "Notch";

            var page = Element as Page;

            if (NotchInfo.Model == PhoneModels.Custom)
            {
                Device = NotchInfo.CustomDevice;
            }
            else
            {
                Device = Devices[NotchInfo.Model];
            }


            var notch = new NotchView(Container.Context) { Device = Device };

            NotchLayout.AddView(notch);
            Container.AddView(NotchLayout);

            NotchLayout.Parent.BringChildToFront(NotchLayout);


            Container.LayoutChange += (sender, e) =>
            {
                if (NotchLayout != null)
                {
                    var msw = MeasureSpec.MakeMeasureSpec(e.Right , MeasureSpecMode.Exactly);
                    var msh = MeasureSpec.MakeMeasureSpec(e.Bottom, MeasureSpecMode.Exactly);

                    NotchLayout.Measure(msw, msh);
                    NotchLayout.Layout(0, 0, e.Right , e.Bottom);

                    NotchLayout.Parent.BringChildToFront(NotchLayout);

                }

            };
        }

        
    }
}

Many of the details of how I've actually implemented the gaphics are within NotchView classes which have been left out of here for brevity but are available in the repo.

Step 4. Consume the view in your xaml page by adding the effect to ContentPage.Effects.

I've included a bunch of presets with my implementation so previewing is as easy as

<ContentPage.Effects>
     <local:NotchEffect Model="iPhoneXSMax"/>
</ContentPage.Effects>

or

<ContentPage.Effects>
    <local:NotchEffect Model="Pixel3XL"/>
</ContentPage.Effects>

If you want to try a custom device you can set the Model to custom and tweak parameters to match.

<ContentPage.Effects>
    <local:NotchEffect
        Model="Custom">
        <local:NotchEffect.CustomDevice>
            <local:Device
                NotchX="370"
                NotchWidth="80"
                NotchHeight="58"
                NotchTopRadius="18"
                CornerRadius="45"
                NotchBottomRadius="40"
                >
            </local:Device>
        </local:NotchEffect.CustomDevice>
    </local:NotchEffect>
</ContentPage.Effects>

Using our new d: xmlns we can disable it at run time and with OnPlatform we can have presets to match the selected device as we swap back and forth between iOS and Android

<ContentPage.Effects>
    <local:NotchEffect  d:Model="{OnPlatform iOS=iPhoneXSMax Android=Pixel3XL}"
                        Model="Default"/>
</ContentPage.Effects>

Here we have the same screen on a Huawei P30, Pixel 3XL and iPhoneXSMax. I can change back and forth between the three without having to wait for building or buying expensive devices.

The same page previewed on a P30, Pixel 3XL and iPhoneXSMax

The P30 looks fine but I've some writing on the Pixel and a little of the Xamagon fell off the iPhone XSMax.

What next?

The notch and corners are just the beginning of what can be done in this style to make the previewer more useful. Placeholders for navigation and tab bars can be added to help gauge how the contents will fit in the context of a full app or the size of the previewer can be adjusted to show just the size of a cell when working on a DataTemplate.

Appendix

A few observations that don't quite fit in the blog post but are relevant and interesting:

  • iOS Previewer always identifies it self as an iPhone 6 with a resolution of 320 x 480dp. The notch effect doesn't know the device screen size in the previewer so it needs to be specified in the Device class.
  • Recommended listening while reading this blog post is The Buggles' second album - Adventures in Modern Recording. They inspired my blog post title. Go listen, they had more than one song.
  • Using the standard NavigationBar will make your app notch-proof on both iOS and Android. This approach is for when you want full screen glory.
  • You can enable a simulated notch of Android devices and emulators.
  • There was a leak today suggesting Apple will kill the notch by 2021 so this post's days are numbered. https://www.macrumors.com/2019/07/10/notch-less-iphone-2020-rumor/
  • Xamarin Hot Reload has just been launched making this post less relevant https://devblogs.microsoft.com/xamarin/xaml-hot-reload/
  • Sorry to wait until the end of this post to drop those bombs.

Credits

Thanks to James Montemagno for his Xamarin Plugins template for Visual Studio, check it out in the extensions manager in VS.

Kym Phillpotts' XFUtils repo was useful for setting up my multitargeting settings to get an effect wired up in a Nuget. https://github.com/kphillpotts/XFUtils

Ray Wenderlich's article on drawing arcs was incredibly useful https://www.raywenderlich.com/349664-core-graphics-tutorial-arcs-and-paths. Circles are weird.