Tuesday, June 2, 2015

Building the Swiper control (the iOS part)

This is going to be a rather long post about building the Swiper control for iOS. There is also a WP and an Android-renderer available but in this post I'll focus on iOS.

The Swiper control is a image flipping control that displays images in a long line and allows for lazy-loading of new images.

All source code is available at GitHub (https://github.com/johankson/flipper) and the bits can also be downloaded through nuget (http://www.nuget.org/packages/Flipper.Forms/).

What's the purpose with this blog post?

The purpose is to show you how the swiper control is built from an iOS perspective.


Introduction

This control is a standard Xamarin Forms control. That means that there are two major parts;
  • The shared code in the PCL
  • One renderer for each platform
In the shared code we declare our control in a platform agnostic way. I've chosen to name it Swiper.cs (upper blue arrow). Each platform then has a corresponding renderer if they do custom rendering that is. In this case the lower blue arror pointing at the file named SwiperRenderer.cs.


We're going to look into details for each of these files in a little while. But first I think we should start by looking at how the control is used and what it looks like in action.

How is it used?

We're going to start at the end where it gets used. In a XAML-page, the example is from the sample app included in the project.

The first thing you need to do is to declare a namespace so that we can reference the control.

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:flipper="clr-namespace:Flipper.Controls;assembly=Flipper"
             x:Class="Flipper.Sample.Views.SwiperView">

After the namespace is declared, you are free to use the control.

        <flipper:Swiper Source="{Binding Items}" 
                        IsNearEnd="{Binding EndIsNearCommand}" 
                        SelectedIndex="{Binding Index, Mode=TwoWay}"
                        SelectedUrl="{Binding Url, Mode=TwoWay}"
                        NearEndTreshold="4" />

The only mandatory property that you need to set is the Source property that is an ObservableCollection that contains a list of image URLs. The reason for it being observable is that the control needs to react to you adding (or removing) images to the list and update stuff based on that.

To help you with the lazy loading you can specify a command to be executed when the user is closing in to the end of the list. The name of this command is IsNearEnd. You can control the timing of this by setting the NearEndTreshold property to fire the command when the desired number of images are left.

The SelectedUrl and SelectedIndex can be set and read. Please note that the URL must exist in the already supplied list. I was thinking about adding a behavior that simply adds a new URL to the end of the list if the URL isn't in the list.

So what does it look like?

This is 29 amazing seconds of random image flipping...


Ok, show me some code!

Let's start with the platform agnostic part. I've chosen to inherit from the Xamarin Forms View class since it best fits my needs of behavior. I'm not going to show all the code here since it would just take up a lot of space. For the full sample, check out the source at Github.

  public class Swiper : View
  {
        public static readonly BindableProperty SourceProperty =
            BindableProperty.Create<Swiper, ObservableCollection<string>>(
            (p) => p.Source, null);

       public ObservableCollection<string> Source
        {
            get { return (ObservableCollection<string>)GetValue(SourceProperty); }
            set { SetValue(SourceProperty, value); }
        }

The second thing you need to do is to define static BindableProperty objects for each property that you want to bindable and then map these to the actual member property of the class. In a nutshell, this is what my platform agnostic piece of the Swiper control do. It could do more. One example is to move the code that determines if we are getting close to the end of our image list. This code is now in each of the three renderers... Bad practice... Well, no ones perfect.

The renderer

Most of the code is within the renderer class in the iOS project. To allow for the Xamarin Forms framework to be aware of what renderer that goes with what control, you need to register them. In our case it looks like this:

[assembly: ExportRenderer(typeof(Swiper), typeof(SwiperRenderer))]

It declares the fact that if the framework needs to render a control of the type of Swiper, please use the SwiperRenderer. In fact, you can create a renderer for any Xamarin forms control. For example overriding a Label by inheriting form a LabelRenderer and add custom logic to it. This line of code must be outside any namespace declaration.

The SwiperRenderer class inherits from ViewRenderer and here's where the platform specific magic kicks in. Notice the UIView at the end of the generic declaration. This is a "native" (everything is native, but this is more native) iOS UIView that we are going to render our stuff on.

Some image swapping theory first

The way I created this control is not the best way, the first way or the worst way. It's just one way of doing it. I did it to learn more about custom renderers.

What I did is that I took three UIImageView object called them left, middle and right. If we just look at the control, the middle image is the one you see. I then listen to touch events and track drag along the x axis, repositioning the images as the user moves his/hers finger. If the delta value of the drag is above a certain threshold when the user ends the draw the images are animated either left or right. As soon as the images are animated the position of the UIImageView objects are reset and the current index is updated and images are reset. You never see the "jump". 

The normal flow

Right after the renderer is created, the OnElementChanged(...) method is called. This is called once in the renderers lifetime (normally) and lets you create the native control and pass it back to Xamarin forms. It looks like this:
      protected async override void OnElementChanged(ElementChangedEventArgs<Swiper> e)
        {
            base.OnElementChanged(e);

            if (this.Element == null)
            {
                return;
            }

            _leftImageView = CreateImageView();
            _rightImageView = CreateImageView();

            _centerImageView = CreateImageView();
            _centerImageView.UserInteractionEnabled = true;
            _centerImageView.AddGestureRecognizer(new UIPanGestureRecognizer(OnPan));

            UpdateSizes();

            _rootView = new UIView();
            _rootView.ContentMode = UIViewContentMode.ScaleAspectFit;
            _rootView.AddSubview(_centerImageView);
            _rootView.AddSubview(_leftImageView);
            _rootView.AddSubview(_rightImageView);

            this.SetNativeControl(_rootView);

            if (this.Element.Width > 0 && this.Element.Height > 0)
            {
                await InitializeImagesAsync();
            }
        }

The important parts here are:
  • We add a gesture recognizer to the center image. That's the only one that needs one.
  • We update sizes, setting the frame of the image and position them in a fine row.
  • We create a UIView and add three subviews to it.
  • We then call InitializeImagesAsync() that loads the correct images onto the three images displayed.
Then each time a property is updated on the control the framework calls OnElementPropertyChanged(...) and that one looks like this:

 protected async override void OnElementPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
        {
            base.OnElementPropertyChanged(sender, e);

            if (e.PropertyName == Swiper.SourceProperty.PropertyName)
            {
                await InitializeImagesAsync();
            }
            ... more code omitted - it looks the same
       }

Here you can react to changes in properties. In the example above, if the Source property changes we need to reinitialize our images. In fact, we reinitalize them a lot.

And finally, the OnPan method we wired up to handle any drag events handle the interaction with the user. Check it out in the sample, it's long but self explanatory.

Also, worth noting, there is no draw call since we don't need to override it. iOS is very good at recognizing invalidation of the UI and redraws just fine without us bothering it. The Android version of this control however is mainly manual drawing. Mostly for fun...

What about caching

Well, there is still job to do here. At the moment, I use a simple Dictionary that has no concept of size management and storing to disk. It is destroyed as soon as the user navigates away from the swiper.
        // Primitive cache - no life time management or cleanup - also stores the image in full size.
        // Thinking about abstracting the cache away and inject it instead to make sure it can be
        // replaced during runtime.
        private Dictionary<string, byte[]> _cache = new Dictionary<string, byte[]>();

How does the loading overlay work

Oh you noticed that, nice! When I said that I had three UIImageView objects... I lied, I have three AsyncUIImageView objects that inherit from UIImageView that simply adds an overlay if an image hasn't loaded yet.

Any other special cases

I needed to set the alpha to 0 to make the "-1" picture invisible so you don't see it when scrolling left of the first picture. Clearing the Image source didn't work. Now that I think about it, that should work... I need to investigate that again.

Summary

This is just version 1 of the control. If you have suggestions, please add an issue to the Github project directly and feel free to steal any code you fancy.

2 comments:

  1. Nice post, thanks for sharing! I think with a couple of mods this will be very useful for one of the views I have to build. Cheers from Florida!

    ReplyDelete
    Replies
    1. Just add issues to the github repository and we'll add what you need!

      Delete