Статьи

Создание клиента Imgur для Windows Phone — часть 2 — бесконечная прокрутка изображений

В предыдущей статье этой серии я говорил о том, как можно загружать изображения из основной галереи, а также о том, как отображать небольшое подмножество изображений на главной странице. Сегодня я расскажу о способе добавления большего количества изображений в ListBox, их загрузки по мере необходимости, а также о способе просмотра деталей изображения, если пользователь нажимает на один из элементов.

Итак, начнем с ListBox. В настоящее время, когда загружается главная страница, я проверяю, есть ли что-нибудь в контейнере HomeImages — там хранятся все активные изображения. Существует также дополнительный контейнер — DeserializedHomeImages , который содержит ссылки на изображения, но не сами изображения.

Глядя на код, который мы имеем, вы можете видеть, что в настоящее время я загружаю только 10 изображений:

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));
    });

Которые затем отображаются в основном виде:

Отлично, но 10 изображений — это не совсем то, что мы ищем, поскольку в справочном контейнере есть еще много других ( DeserializedHomeImages ). Так, как мы продолжаем добавлять больше к связанной коллекции?

Нам нужно убедиться, что мы можем определить, когда пользователь прокручивает список до конца. Не существует обработчика событий по умолчанию, но мы, безусловно, можем реализовать пользовательский элемент управления, который уведомляет приложение, когда пользователь достиг конца текущего набора. Вдохновением для моей реализации этого было управление, задокументированное здесь Эриком . Я портировал его с VB на C #.

Я создаю новую папку Controls в проекте и добавляю новый класс InfiniteListBox . Этот класс наследует от стандартного ListBox, но также предоставляет надлежащие хуки, позволяющие разработчику знать, когда происходит сжатие, поэтому завершение списка происходит.

Вот содержимое файла:

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
    }
}

Во внутреннем интерфейсе ничего особенного не происходит, кроме обхода визуального дерева для поиска
ScrollViewer и связанных
экземпляров
VisualStateGroup для горизонтального и вертикального сжатия.

Чтобы это работало, вам нужно определить собственный стиль для
элемента управления ScrollViewer . Еще раз, я использовал оригинал из статьи Эрика:

<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>

Я бы порекомендовал включить его в
App.xaml , чтобы он был общедоступным ресурсом. Возвращаясь к
MainPage.xaml , где отображается содержимое, я
заменяю ListBox на
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>

Пространство
имен
элементов управления было объявлено в заголовке файла XAML как:

xmlns:controls="clr-namespace:Imagine.Controls"

Обратите внимание, что объявления шаблона элемента и панели элементов не изменяются. Но теперь есть перехваченное
событие
CompressionOccured :

private void mainList_CompressionOccured_1(object sender, Controls.CompressionEventArgs args)
{
    Debug.WriteLine(args.Type.ToString());
}

В целях тестирования я просто пишу в консоли вывода, что список достиг определенного состояния сжатия. И это действительно работает:

теперь давайте вернемся к интересной части. Как вы уже знаете, основной эталонный контейнер изображений содержит изображения всех форматов. Однако приложения Windows Phone не могут отображать изображения GIF в элементах управления изображения. Эта проблема решается, когда я вызываю DownloadImages, который отфильтровывает весь стек кандидатов в GIF. Но это сбрасывает общий счетчик изображений, и чтобы нам не пришлось загружать ненужный контент, я подумал, что пока я полностью исключу GIF-файлы из витрины главной галереи.

ПРИМЕЧАНИЕ: я представлю их еще в той части серии, где я освещаю анимационные галереи.

Мне нужно удалить все изображения GIF из
MainPageViewModel.Instance.DeserializedHomeImages . В Silverlight 5 (и Windows Phone 8) я мог бы вызвать
Список

.Удалить все
, Однако этот метод расширения недоступен в приложениях Windows Phone 7.1 (Mango).

Поэтому я подумал, что правильная реализация в порядке. В
папке
Core я создал
класс
GenericHelper , который включает
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);
        }
    }
}

В целом, я не хочу отображать альбомы и GIF-файлы, поэтому, прежде чем я перейду к маршрутизации загрузки изображений, я могу назвать это волшебством:

MainPageViewModel.Instance.DeserializedHomeImages.RemoveAll(p => p.IsAlbum == true);
MainPageViewModel.Instance.DeserializedHomeImages.RemoveAll(image => image.Link.EndsWith(".gif"));

Очистив реализацию, давайте избавимся от привязки BitmapImage и вместо этого будем связываться непосредственно со ссылкой. Это исключит
вспомогательную функцию
DownloadImage . Нам по-прежнему нужно добавлять изображения порциями, поэтому мы используем
для этого функцию
AddSelectItems :

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;
        }
    }
}

Поскольку я получаю необходимые предметы, я удаляю те, которые уже использовались, из десериализованного контейнера. Таким образом, если пользователь быстро прокручивает страницу и добавляет новые элементы, одно и то же изображение не будет отображаться дважды.

Эту функцию можно вызывать в
обработчике событий
CompressionOccured , загружая больше элементов по мере прокрутки пользователями вниз списка.

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

Когда я связываю элемент управления Image со свойством Link, мне нужно загрузить только миниатюру, поэтому я использую вспомогательный конвертер, чтобы получить правильный 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();
    }
}

Вы можете скачать исходный код
здесь .