Why didn't I just use the native controls?
I've been getting some comments about the iOS and Android implementation for this, stating that I could have done this a lot simpler by using the native controls (the ViewPager and the UICollectionView). This is perfectly true, but it wasn't the purpose why I created the control.The reasons I choose to do it the way I did was
- I originally planned to add custom graphic effects
- I wanted to see if I could make it perfectly fluid on my own
Having that said, I might convert the control to use the ViewPager and the UICollectionView and create another experimental Swiper as separate control. I got lazy for the WP implementation of the renderer and used a Panorama. So I'm kind of in between the two ways to do this.
So what's the theory behind the Droid renderer?
I went even more back to basic this time. I decided to juggle pure bitmaps and override the Draw(...) method of the renderer. This means we are doing pure rendering on demand of the entire control.
If we start from the top, you'll see that this time, the renderer inherits from ViewRenderer where View is the thin wrapper for an Android View which is the most basic building block for an Android GUI thingy. View itself inherits from java.lang.Object so it's pretty much bare metal from here on.
As with the iOS version we need to override a few methods to get going
- OnElementChanged
- OnElementPropertyChanged
- Draw
- OnTouchEvent
OnElementChanged
The first one being OnElementChanged which is called by Xamarin Forms when it's time to create a platform specific object. (I don't like to use the word Native since it implies that Xamarin isn't native). Anyhow, the method looks like this. It's way shorter than the iOS counter-part.
protected override void OnElementChanged(ElementChangedEventArgs<Swiper> e)
{
base.OnElementChanged(e);
UpdateSizes();
_rootView = new View(Context);
SetNativeControl(_rootView);
}
{
base.OnElementChanged(e);
UpdateSizes();
_rootView = new View(Context);
SetNativeControl(_rootView);
}
The first thing we do is call UpdateSizes that simple copies size data into local variables for easier lookup later on.
private void UpdateSizes()
{
if (this.Element == null)
{
return;
}
if (this.Width > 0 && this.Height > 0)
{
_width = this.Width;
_halfWidth = _width / 2;
_height = this.Height;
_halfHeight = _height / 2;
}
}
{
if (this.Element == null)
{
return;
}
if (this.Width > 0 && this.Height > 0)
{
_width = this.Width;
_halfWidth = _width / 2;
_height = this.Height;
_halfHeight = _height / 2;
}
}
Then we create the platform specific control and set it as the "Native" control. Since this is Android we need to pass the context to every corner of our code. I'm pretty sure the Android team use that as the solution to everything in life, as long as we have a context... We're fine!
At this point we have a control that will do the rendering for us.
OnElementPropertyChanged
This method is a lookalike to the iOS counterpart and I should really look into sharing some more code here by abstracting the events that go on in the renderers. All renderers have an InitializeImages method for example. I could define an interface for all common stuff and create a platform agnostic controller... Well, I didn't so we're stuck with code duplication for the time being. I didn't include the whole method in the sample below, simply the first two as an example of what it looks like.
protected override void OnElementPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
{
base.OnElementPropertyChanged(sender, e);
if (e.PropertyName == Swiper.SourceProperty.PropertyName)
{
InitializeImages();
}
if (e.PropertyName == Swiper.WidthProperty.PropertyName || e.PropertyName == Swiper.HeightProperty.PropertyName)
{
UpdateSizes();
}
if (e.PropertyName == Swiper.SelectedIndexProperty.PropertyName)
{
// TODO Check for index overrun
if (this.Element.SelectedIndex > 0 &&
_currentImageUrl != this.Element.Source[this.Element.SelectedIndex])
{
_currentImageUrl = this.Element.Source[this.Element.SelectedIndex];
InitializeImages();
}
}
// Code omitted (there's more in this method)
}
{
base.OnElementPropertyChanged(sender, e);
if (e.PropertyName == Swiper.SourceProperty.PropertyName)
{
InitializeImages();
}
if (e.PropertyName == Swiper.WidthProperty.PropertyName || e.PropertyName == Swiper.HeightProperty.PropertyName)
{
UpdateSizes();
}
if (e.PropertyName == Swiper.SelectedIndexProperty.PropertyName)
{
// TODO Check for index overrun
if (this.Element.SelectedIndex > 0 &&
_currentImageUrl != this.Element.Source[this.Element.SelectedIndex])
{
_currentImageUrl = this.Element.Source[this.Element.SelectedIndex];
InitializeImages();
}
}
// Code omitted (there's more in this method)
}
As with the iOS version, we listen for changes in properties and call the appropriate methods to handle this change. For example, if we change the source we need to reinitialize the images. If we change the width or height we need to update sizes. Those sizes are needed to render later on.
Async image downloading
I played around with a couple of different approaches to async image downloading. I ended up with the basic WebClient since it would be cool to have download progress if the images are large. Looking at the code now, I realize that it's not fully implemented yet. I registered an issue (#13) for this and hopefully I'll get something done. The downloading works but the showing the progress is not completed yet. We just draw a loading text.
We directly convert the downloaded bits into a Android Bitmap object.
We directly convert the downloaded bits into a Android Bitmap object.
private async Task LoadWithProgress()
{
try
{
var webClient = new WebClient();
webClient.DownloadProgressChanged += webClient_DownloadProgressChanged;
var bytes = await webClient.DownloadDataTaskAsync(new Uri(_url));
_bitmap = await BitmapFactory.DecodeByteArrayAsync(bytes, 0, bytes.Length);
if (Completed != null && _bitmap != null)
{
Completed(this);
}
}
catch (Exception ex)
{
Log.Debug("SwipeRenderer", "Exception loading image '{0}' using WebClient", _url);
}
}
void webClient_DownloadProgressChanged(object sender, DownloadProgressChangedEventArgs e)
{
int i = 42;
}
{
try
{
var webClient = new WebClient();
webClient.DownloadProgressChanged += webClient_DownloadProgressChanged;
var bytes = await webClient.DownloadDataTaskAsync(new Uri(_url));
_bitmap = await BitmapFactory.DecodeByteArrayAsync(bytes, 0, bytes.Length);
if (Completed != null && _bitmap != null)
{
Completed(this);
}
}
catch (Exception ex)
{
Log.Debug("SwipeRenderer", "Exception loading image '{0}' using WebClient", _url);
}
}
void webClient_DownloadProgressChanged(object sender, DownloadProgressChangedEventArgs e)
{
int i = 42;
}
Caching
The caching in the Android version is just as basic. We simply store a dictionary of string and AsyncImageLoader for now. It's the same with all three platforms and a common caching strategy is required here.
Tracking your fingers
Handling touch events is easy. Simply override the OnTouchEvent method and check what type of event is being raised. We can break down this method in three parts categorized by the action type.
public override bool OnTouchEvent(MotionEvent e)
{
switch(e.Action)
{
case MotionEventActions.Down:
_swipeStartX = e.GetX();
return true;
case MotionEventActions.Move:
_swipeCurrectXOffset = e.GetX() - _swipeStartX;
Invalidate();
return true;
case MotionEventActions.Up:
var index = this.Element.Source.IndexOf(_currentImageUrl);
if(Math.Abs(_swipeCurrectXOffset)>30) // TODO Add a variable for the trigger offset?
{
if(_swipeCurrectXOffset > 0 && index > 0)
{
// Left swipe
AnimateLeft(index);
}
else if (_swipeCurrectXOffset < 0 && index < this.Element.Source.Count() -1)
{
// Right swipe
AnimateRight(index);
}
else
{
AnimateBackToStart();
}
}
else
{
AnimateBackToStart();
}
return true;
}
return base.OnTouchEvent(e);
}
- The user starts a touch and we get a MotionEventActions.Down. We store the start location of the X-axis of the touch position to keep for later reference.
- If the action is Move we calculate the offset value of the current finger position on the X-axis. Then we call Invalidate() to force a redraw of the control.
- When the user lets go of the image we need to check if the image has moved far enough to count as a image switch motion and in that case, what direction. If it's not a switch we still need to animate the images back into the original place.
public override bool OnTouchEvent(MotionEvent e)
{
switch(e.Action)
{
case MotionEventActions.Down:
_swipeStartX = e.GetX();
return true;
case MotionEventActions.Move:
_swipeCurrectXOffset = e.GetX() - _swipeStartX;
Invalidate();
return true;
case MotionEventActions.Up:
var index = this.Element.Source.IndexOf(_currentImageUrl);
if(Math.Abs(_swipeCurrectXOffset)>30) // TODO Add a variable for the trigger offset?
{
if(_swipeCurrectXOffset > 0 && index > 0)
{
// Left swipe
AnimateLeft(index);
}
else if (_swipeCurrectXOffset < 0 && index < this.Element.Source.Count() -1)
{
// Right swipe
AnimateRight(index);
}
else
{
AnimateBackToStart();
}
}
else
{
AnimateBackToStart();
}
return true;
}
return base.OnTouchEvent(e);
}
Animation of images
Each platform offers different kind of animation APIs. In this sample I've chosen to use the static ValueAnimator to animate a float value. We can take the AnimateLeft(...) method as an example. It's dead simple to use. Simply state the initial value and the end value in the ValueAnimator.OfFloat(...). Hook up events for Update (that fires every frame) and for AnimationEnd(that fires when the end result has been achieved).
For this specific function we continue to animate the current x-offset to the left and when we hit the end we set a new _currentImageUrl (that represents the center image) and reinitialize all images so the new images are displayed.
private void AnimateLeft(int index)
{
var animator = ValueAnimator.OfFloat(_swipeCurrectXOffset, this.Width);
animator.Start();
animator.Update += (object sender, ValueAnimator.AnimatorUpdateEventArgs args) =>
{
_swipeCurrectXOffset = (float)args.Animation.AnimatedValue;
Invalidate();
};
animator.AnimationEnd += (object sender, EventArgs args) =>
{
_swipeCurrectXOffset = 0f;
_currentImageUrl = this.Element.Source[index - 1];
InitializeImages();
};
}
{
var animator = ValueAnimator.OfFloat(_swipeCurrectXOffset, this.Width);
animator.Start();
animator.Update += (object sender, ValueAnimator.AnimatorUpdateEventArgs args) =>
{
_swipeCurrectXOffset = (float)args.Animation.AnimatedValue;
Invalidate();
};
animator.AnimationEnd += (object sender, EventArgs args) =>
{
_swipeCurrectXOffset = 0f;
_currentImageUrl = this.Element.Source[index - 1];
InitializeImages();
};
}
Drawing
The method signature of Draw(...) looks like this.
public override void Draw(Android.Graphics.Canvas canvas)
It passes in a single argument in the form of a Canvas object. This Canvas represents the drawable surface that we have access to. The Draw(...) method is called everytime Invalidate() is called else where in the code or when the operating system wants you to update.
The method is quite repetitive so I'll just take a sample out of it.
// Clear the canvas
canvas.DrawARGB(255, 255, 255, 255);
if(_centerBitmap != null && _centerBitmap.Bitmap != null)
{
var dest = CalculateCentrationRect(_centerBitmap.Bitmap);
canvas.DrawBitmap(_centerBitmap.Bitmap, dest.Left + _swipeCurrectXOffset, dest.Top, null);
}
else if (_centerBitmap != null)
{
DrawLoadingText(canvas, 0);
}
This is pretty much what's going on, but times three. One for each image. First we need to clear the frame from the previous stuff drawn onto it. We do that with the canvas.DrawARBG(...) call. This could easily be extended to take a background color or image instead.
Then for each image we either draw the image or draw a loading text if the image isn't downloaded yet.
Summary
You could have made this a lot simpler and perhaps I'll revisit this control and redo it. But as a learning experience it was quite fun. Feel free to steal any code! My code is your code!