Mobile Zone is brought to you in partnership with:

Den is a DZone Zone Leader and has posted 460 posts at DZone. You can read more from them at their website. View Full User Profile

Building an Imgur Client for Windows Phone - Part 2 - Infinite Image Scroll

01.16.2013
| 3016 views |
  • submit to reddit

In the previous article in the series I talked about how it is possible to load the main gallery images, as well as how to display a small subset of those on the main page. Today I am going to talk about a way to add more images in the ListBox, loading them as the user needs them, as well as about a way to view image details if the user taps on one of the items.

So let's begin with the ListBox. Currently, when the main page is loaded, I am checking whether there is anything in the HomeImages container - that's where all active images are stored. There is also an additional container - DeserializedHomeImages, that contains the image references, but not the images themselves.

Looking at the code we have, you can see that currently I am loading only 10 images:

ImageDownloadHelper.DownloadImages(MainPageViewModel.Instance.DeserializedHomeImages, 0, 10, (image) =>
    {
        MainPageViewModel.Instance.HomeImages.Add(image);
        Debug.WriteLine(string.Format("[{0}] Image added to HomeImages in MainPageViewModel.",
            image.Type));
    });

Which are then displayed in the core view:

Perfect, but 10 images are not exactly what we're looking for, since there are many more out there in the reference container (DeserializedHomeImages). So how do we keep adding more to the bound collection?

We need to make sure that we can detect when the user scrolled to the bottom of the list. There is no default event handler, but we can surely implement a custom control that notifies the application when the user reached the end of the current set. The inspiration behind my implementation for this was the control documented by Eric here. I ported it from VB to C#.

I create a new Controls folder in the project and added an InfiniteListBox new class. This class inherits from a standard ListBox, but also provides the proper hooks to let the developer known when a compression, therefore list finalization, event occurs.

Here are the contents of the file:

using System;
using System.Collections;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;

namespace Imagine.Controls
{
    public class InfiniteListBox : ListBox
    {
        public delegate void OnCompression(object sender, CompressionEventArgs args);
        public event OnCompression CompressionOccured;

        private bool scrollEventsHooked = false;

        public InfiniteListBox()
        {
            this.Loaded += InfiniteListBox_Loaded;
        }

        void InfiniteListBox_Loaded(object sender, RoutedEventArgs e)
        {
            PrepareCompressionTracking();
        }
        
        private void PrepareCompressionTracking()
        {
            ScrollViewer scrollViewer = null;

            if (scrollEventsHooked)
                return;

            scrollViewer = FindFirstElement(this, typeof(ScrollViewer)) as ScrollViewer;

            if (scrollViewer != null)
            {
                FrameworkElement element = VisualTreeHelper.GetChild(scrollViewer, 0) as FrameworkElement;

                if (element != null)
                {
                    var verticalStateGroup = FindVisualStateGroup(element, "VerticalCompression");
                    var horizontalStateGroup = FindVisualStateGroup(element, "HorizontalCompression");

                    if (verticalStateGroup != null)
                    {
                        verticalStateGroup.CurrentStateChanging += verticalStateGroup_CurrentStateChanging;
                    }

                    if (horizontalStateGroup != null)
                    {
                        horizontalStateGroup.CurrentStateChanging += horizontalStateGroup_CurrentStateChanging;
                    }
                }
            }

            scrollEventsHooked = true;
        }
  
        void horizontalStateGroup_CurrentStateChanging(object sender, VisualStateChangedEventArgs e)
        {
            if (e.NewState.Name == "CompressionLeft")
            {
                CompressionOccured(this, new CompressionEventArgs(CompressionType.Left));
            }
            else if (e.NewState.Name == "CompressionRight")
            {
                CompressionOccured(this, new CompressionEventArgs(CompressionType.Right));
            }
        }

        void verticalStateGroup_CurrentStateChanging(object sender, VisualStateChangedEventArgs e)
        {
            if (e.NewState.Name == "CompressionTop")
            {
                CompressionOccured(this, new CompressionEventArgs(CompressionType.Top));
            }
            else if (e.NewState.Name == "CompressionBottom")
            {
                CompressionOccured(this, new CompressionEventArgs(CompressionType.Bottom));
            }
        }

        private VisualStateGroup FindVisualStateGroup(FrameworkElement parent, string name)
        {
            if (parent == null)
                return null;

            IList groups = VisualStateManager.GetVisualStateGroups(parent);
            foreach (VisualStateGroup group in groups)
            {
                if (group.Name == name)
                    return group;
            }

            return null;
        }

        private UIElement FindFirstElement(FrameworkElement parent, Type targetType)
        {
            int childCount = VisualTreeHelper.GetChildrenCount(parent);
            UIElement returnedElement = null;

            if (childCount > 0)
            {
                for (int i = 0; i < childCount; i++)
                {
                    var element = VisualTreeHelper.GetChild(parent, i);
                    if (element.GetType().Equals(targetType))
                    {
                        returnedElement = (UIElement)element;
                        break;
                    }
                }
            }

            return returnedElement;
        }
    }

    public class CompressionEventArgs : EventArgs
    {
        CompressionType _type;

        public CompressionType Type
        {
            get
            {
                return _type;
            }
            set
            {
                _type = value;
            }
        }

        public CompressionEventArgs(CompressionType type)
        {
            _type = type;
        }
    }

    public enum CompressionType
    {
        Top,
        Bottom,
        Left,
        Right
    }
}
There is not much going on in the backend other than the visual tree being traversed to find the ScrollViewer and the associated VisualStateGroup instances for horizontal and vertical compression.

For this to work, you need to define a custom style for the ScrollViewer control. Once again, I've used the original from Eric's article:
<Style TargetType="ScrollViewer">
    <Setter Property="VerticalScrollBarVisibility" Value="Auto"/>
    <Setter Property="HorizontalScrollBarVisibility" Value="Auto"/>
    <Setter Property="Background" Value="Transparent"/>
    <Setter Property="Padding" Value="0"/>
    <Setter Property="BorderThickness" Value="0"/>
    <Setter Property="BorderBrush" Value="Transparent"/>
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="ScrollViewer">
                <Border BorderBrush="{TemplateBinding BorderBrush}" 
                        BorderThickness="{TemplateBinding BorderThickness}" 
                        Background="{TemplateBinding Background}">
                    <VisualStateManager.VisualStateGroups>
                        <VisualStateGroup x:Name="ScrollStates">
                            <VisualStateGroup.Transitions>
                                <VisualTransition GeneratedDuration="00:00:00.5"/>
                            </VisualStateGroup.Transitions>
                            <VisualState x:Name="Scrolling">
                                <Storyboard>
                                    <DoubleAnimation Storyboard.TargetName="VerticalScrollBar" 
                                                     Storyboard.TargetProperty="Opacity" 
                                                     To="1" Duration="0"/>
                                    <DoubleAnimation Storyboard.TargetName="HorizontalScrollBar" 
                                                     Storyboard.TargetProperty="Opacity" To="1" 
                                                     Duration="0"/>
                                </Storyboard>
                            </VisualState>
                            <VisualState x:Name="NotScrolling"/>
                        </VisualStateGroup>
                        <VisualStateGroup x:Name="VerticalCompression">
                            <VisualState x:Name="NoVerticalCompression"/>
                            <VisualState x:Name="CompressionTop"/>
                            <VisualState x:Name="CompressionBottom"/>
                        </VisualStateGroup>
                        <VisualStateGroup x:Name="HorizontalCompression">
                            <VisualState x:Name="NoHorizontalCompression"/>
                            <VisualState x:Name="CompressionLeft"/>
                            <VisualState x:Name="CompressionRight"/>
                        </VisualStateGroup>
                    </VisualStateManager.VisualStateGroups>
                    
                    <Grid Margin="{TemplateBinding Padding}">
                        <ScrollContentPresenter x:Name="ScrollContentPresenter" 
                                                Content="{TemplateBinding Content}"
                                                ContentTemplate="{TemplateBinding ContentTemplate}"/>
                        
                        <ScrollBar x:Name="VerticalScrollBar" 
                                   IsHitTestVisible="False" 
                                   Height="Auto" Width="5"
                                   HorizontalAlignment="Right"
                                   VerticalAlignment="Stretch" 
                                   Visibility="{TemplateBinding ComputedVerticalScrollBarVisibility}"
                                   IsTabStop="False" Maximum="{TemplateBinding ScrollableHeight}" 
                                   Minimum="0" Value="{TemplateBinding VerticalOffset}"
                                   Orientation="Vertical" ViewportSize="{TemplateBinding ViewportHeight}" />
                        
                        <ScrollBar x:Name="HorizontalScrollBar"
                                   IsHitTestVisible="False" 
                                   Width="Auto" Height="5"
                                   HorizontalAlignment="Stretch"
                                   VerticalAlignment="Bottom" 
                                   Visibility="{TemplateBinding ComputedHorizontalScrollBarVisibility}"
                                   IsTabStop="False" Maximum="{TemplateBinding ScrollableWidth}" Minimum="0" 
                                   Value="{TemplateBinding HorizontalOffset}" 
                                   Orientation="Horizontal" ViewportSize="{TemplateBinding ViewportWidth}" />
                    </Grid>
                </Border>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>
I would recommend including it in App.xaml, in order for it to be an app-wide accessible resource. Going back to MainPage.xaml, where the contents are being rendered, I am replacing the ListBox with InfiniteListBox:
<controls:InfiniteListBox x:Name="mainList"
    CompressionOccured="mainList_CompressionOccured_1"
    ItemsSource="{Binding Path=Instance.HomeImages,
    Source={StaticResource MainPageViewModel}}">
    <controls:InfiniteListBox.ItemTemplate>
        <DataTemplate>
            <Image Stretch="UniformToFill" Height="240" Width="240" Source="{Binding Image}"></Image>
        </DataTemplate>
    </controls:InfiniteListBox.ItemTemplate>

    <controls:InfiniteListBox.ItemsPanel>
        <ItemsPanelTemplate>
            <toolkit:WrapPanel ItemWidth="240" ItemHeight="240"/>
        </ItemsPanelTemplate>
    </controls:InfiniteListBox.ItemsPanel>
</controls:InfiniteListBox>
The controls namespace was declared in the XAML file header as:
xmlns:controls="clr-namespace:Imagine.Controls"
Notice that the item and item panel template declarations do not change. But there is now a hooked CompressionOccured event:
private void mainList_CompressionOccured_1(object sender, Controls.CompressionEventArgs args)
{
    Debug.WriteLine(args.Type.ToString());
}
For testing purposes, I am simply writing in the output console that the list has reached a specific compression state. And it sure works:

Now let's get back to the interesting part. As you already know, the main image reference container has images of all formats. However, Windows Phone applications are not able to render GIF images in Image controls. This problem is handled when I am calling DownloadImages, which filters out the entire stack of GIF candidates. But this throws off the total image counter, and so that we won't have to download unnecessary content, I thought that for now I will eliminate GIFs completely from the main gallery showcase.

NOTE: I will introduce those back in the part of the series where I cover animation-based galleries.

I need to eliminate all GIF images from MainPageViewModel.Instance.DeserializedHomeImages. In Silverlight 5 (and Windows Phone 8) I could call List.RemoveAll. This extension method, however, is not available in Windows Phone 7.1 (Mango) applications.

So I thought that a proper implementation is in order. In the Core folder, I created a GenericHelper class, that includes RemoveAll<T>:
using System;
using System.Collections.Generic;

namespace Imagine.Core
{
    public static class GenericHelper
    {
        public static void RemoveAll<T>(this List<T> list, Func<T, bool> filter)
        {
            if (filter == null)
                throw new ArgumentException("filter");

            if (list == null)
                throw new ArgumentException("list");

            int index = 0;
            while ((index < list.Count) && !filter(list[index]))
            {
                index++;
            }

            if (index >= list.Count)
            {
                return;
            }
            
            int secondaryCounter = index + 1;
            while (secondaryCounter < list.Count)
            {
                while ((secondaryCounter < list.Count) && filter(list[secondaryCounter]))
                {
                    secondaryCounter++;
                }
                if (secondaryCounter < list.Count)
                {
                    list[index++] = list[secondaryCounter++];
                }
            }

            list.RemoveRange(index, list.Count - index);
        }
    }
}
Overall, I don't want to display albums and GIFs, so before I step into the image download routing, I can call this magic:
MainPageViewModel.Instance.DeserializedHomeImages.RemoveAll(p => p.IsAlbum == true);
MainPageViewModel.Instance.DeserializedHomeImages.RemoveAll(image => image.Link.EndsWith(".gif"));
Cleaning up the implementation, let's get rid of the BitmapImage binding, and instead bind directly to the link. This will eliminate the DownloadImage helper function for now. We still need to add images in chunks, though, so we use an AddSelectItems function for this:
private void AddSelectItems(List<ImgurImage> source, int itemCount)
{
    while (itemCount > 0)
    {
        if (source.Count > 0)
        {
            ImgurImage image = source.First();
            MainPageViewModel.Instance.HomeImages.Add(image);
            source.Remove(image);
            itemCount--;
        }
        else
        {
            break;
        }
    }
}
As I am getting the necessary items, I am removing the ones already used from the deserialized container. That way, if the user scrolls to fast and new items are added, the same image will not be displayed twice.

This function can be called in the CompressionOccured event handler, loading more items as the users scrolls to the bottom of the list.

private void mainList_CompressionOccured_1(object sender, Controls.CompressionEventArgs args)
{
    AddSelectItems(MainPageViewModel.Instance.DeserializedHomeImages, 10);
}

When I am binding the Image control to the Link property, I need to download only the thumbnail, so I am using a helper converter to get the proper Url:
public class FullToThumbnailUrlConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        string path = value.ToString();
        path = path.Insert(path.LastIndexOf('.'), "s");
        return path;
    }

    public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}
You can download the source here.