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

Для реализации задуманного будем использовать Unity UI Toolkit в паре с UnityMvvmToolkit. Я уже писал про UnityMvvmToolkit статью вот здесь. С тех пор API притерпел значительные изменения, а точнее, произведен переход на реактивные свойства, плюс появилась поддержка генераторов исходного кода. В общем, если интересно, дайте знать в комментариях, напишу обновлённую статью по ней.

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

Стандартный ListView в Unity UI Toolkit поддерживает виртуализацию из коробки, что сразу упрощает нам работу. Остаётся реализовать только переиспользование моделей представления для каждого элемента в списке.

P.S. В приведенном ниже коде будут использоваться генераторы исходного кода UnityUxmlGenerator и UnityMvvmToolkit.Generator, которые избавят нас от написания кучи шаблонного кода. Сгенерированный код будет также представлен.

P.P.S. Для лучшего понимания происходящего советую ознакомиться с документацией UnityMvvmToolkit. Достаточно будет изучить раздел Quick start.

Reusable Items List

Давайте начнём с написания ReusableItemsList. Это будет список, который позволит переиспользовать элементы хранящиеся в нём. Основные интерфейсы которые он должен реализовать будут: IList<T>, IList, INotifyCollectionChanged. Реализация интерфейса IList необходима, чтобы установить ReusableItemsList в качестве itemsSource у стандартного ListView, а оставшиеся два требует BindableListView из библиотеки UnityMvvmToolkit.

public class ReusableItemsList<T> : List<T>, IList<T>, IList, INotifyCollectionChanged
{
    private int _virtualCount;

    public ReusableItemsList(IEnumerable<T> items) : base(items)
    {
    }

    public new T this[int index]
    {
        get
        {
            var itemsCount = base.Count;

            return itemsCount == 0 ? default : base[index % itemsCount];
        }
    }

    object IList.this[int index]
    {
        get => this[index];
        set => throw new NotImplementedException();
    }

    public new int Count => base.Count + _virtualCount;

    public event NotifyCollectionChangedEventHandler CollectionChanged;

    public void AddVirtualItems(int count)
    {
        _virtualCount += count;
        CollectionChanged?.Invoke(this, default);
    }
}

По функционалу это обычный список, но с небольшими изменениями.

Первое – это способ получения элементов по индексу base[index % itemsCount]. Здесь мы просто зацикливаем возврат существующих элементов из коллекции. Если в коллекции будет 10 реальных элементов, а виртуальных, например, 20, то по индексу 10 вернётся элемент 0, а по индексу 15 вернётся элемент 5. Конечно, если переиспользуемых элементов будет меньше, чем отображается на экране, то подвох будет заметен. Но контроль минимально возможного зачения переиспользуемых элементов мы делегируем классу, который будет использовать эту коллекцию.

Второй момент – это способ подсчета количества элементов в коллекции base.Count + _virtualCount. Здесь мы просто складываем количество реальных элементов и виртуальных. Это свойство ListView использует для обновления полосы прокрутки.

Наконец, метод AddVirtualItems, который увеличивает количество виртуальных объектов и уведомляет об изменениях. Обратите внимание, что в событии CollectionChanged у нас NotifyCollectionChangedEventArgs имеет значение default. Это надо будет учитывать в дальнейшем.

Это необходимый минимум для реализации поставленной задачи.

Images ListView

Далее создадим компонент BindableImageListView, который будет отображать наши изображения. Базовым классом для компонента будет служить абстрактный класс BindableListView<TItemBindingContext, TCollection>, где в качестве коллекции укажем ReusableItemsList переопределив при этом пару методов.

[BindableElement]
public partial class BindableImageListView :
    BindableListView<ImagesItemViewModel, ReusableItemsList<ImagesItemViewModel>>
{
    [BindableProperty(defaultValue: "MaxBindIndex")]
    private IProperty<int> _maxBindIndexProperty;

    protected override void BindItem(VisualElement item, int index, ImagesItemViewModel imagesItem,
        IObjectProvider objectProvider)
    {
        if (_maxBindIndexProperty.Value < index)
        {
            _maxBindIndexProperty.Value = index;
        }

        imagesItem.StartImagesLoading(index);
        base.BindItem(item, index, imagesItem, objectProvider);
    }

    protected override void OnItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
    }
}

Первым делом переопределим метод BindItem. Из основного здесь – обновление значения поля _maxBindIndexProperty, которое будет служить триггером для увеличения виртуальных элементов в коллекции. Ну и, конечно же, вызов метода StartImagesLoading у нашей ViewModel'и для начала загрузки изображений.

Так как у нас при изменении коллекции NotifyCollectionChangedEventArgs будет null, нам также необходимо переопределить метод OnItemsCollectionChanged. Его можем оставить пустым, потому что VirtualizationController при возникновении события OnScroll автоматически получает актуальное значение элементов в коллекции и обновляет полосу прокрутки.

Обратите внимание, что здесь мы используем атрибуты BindableElement и BindableProperty для автоматической генерации шаблонного кода.

BindableImageListView.Uxml.g.cs
// <auto-generated/>
#pragma warning disable
#nullable enable

...

namespace BindableUIElements
{
    partial class BindableImageListView
    {
        [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
		[ExcludeFromCodeCoverage]
		private string BindingMaxBindIndexPath { get; set; }

        [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
        public new class UxmlFactory : UxmlFactory<BindableImageListView, UxmlTraits>
        {
        }

        [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
        public new class UxmlTraits : BindableListView<ImagesItemViewModel, ReusableItemsList<ImagesItemViewModel>>.UxmlTraits
        {
            [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
            private readonly UxmlStringAttributeDescription _bindingMaxBindIndexPath = new() 
                { name = "binding-max-bind-index-path", defaultValue = "MaxBindIndex" };

            [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
            [ExcludeFromCodeCoverage]
            public override void Init(VisualElement visualElement, IUxmlAttributes bag, CreationContext context)
            {
                base.Init(visualElement, bag, context);

                var control = (BindableImageListView) visualElement;
                control.BindingMaxBindIndexPath = _bindingMaxBindIndexPath.GetValueFromBag(bag, context);
            }
        }
    }
}

BindableImageListView.Bindings.g.cs
// <auto-generated/>
#pragma warning disable
#nullable enable

...

namespace BindableUIElements
{
    partial class BindableImageListView : IBindableElement
    {
        [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
		private PropertyBindingData? _maxBindIndexBindingData;

        [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
        [ExcludeFromCodeCoverage]
        public override void SetBindingContext(IBindingContext context, IObjectProvider objectProvider)
        {
            BeforeSetBindingContext(context, objectProvider);
            
			base.SetBindingContext(context, objectProvider);

            if (string.IsNullOrWhiteSpace(BindingMaxBindIndexPath) == false)
			{
				_maxBindIndexBindingData ??= StringExtensions.ToPropertyBindingData(BindingMaxBindIndexPath!);
				_maxBindIndexProperty = objectProvider.RentProperty<int>(context, _maxBindIndexBindingData!);
				_maxBindIndexProperty!.ValueChanged += OnMaxBindIndexPropertyValueChanged;
			}
    
            AfterSetBindingContext(context, objectProvider);
        }

        [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
        [ExcludeFromCodeCoverage]
        public override void ResetBindingContext(IObjectProvider objectProvider)
        {
            BeforeResetBindingContext(objectProvider);

            if (_maxBindIndexProperty != null)
			{
				_maxBindIndexProperty!.ValueChanged -= OnMaxBindIndexPropertyValueChanged;
				objectProvider.ReturnProperty(_maxBindIndexProperty);
				_maxBindIndexProperty = null;
			}
            
			base.ResetBindingContext(objectProvider);

            AfterResetBindingContext(objectProvider);
        }

        [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
		[ExcludeFromCodeCoverage]
		private void OnMaxBindIndexPropertyValueChanged(object sender, int value)
		{
			OnMaxBindIndexPropertyValueChanged(value);
		}

        [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
        partial void BeforeSetBindingContext(IBindingContext context, IObjectProvider objectProvider);

        [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
        partial void AfterSetBindingContext(IBindingContext context, IObjectProvider objectProvider);

        [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
        partial void BeforeResetBindingContext(IObjectProvider objectProvider);

        [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
        partial void AfterResetBindingContext(IObjectProvider objectProvider);

        [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
		partial void OnMaxBindIndexPropertyValueChanged(int value);
    }
}

Image Info

Теперь нам необходимо создать шаблон ImageInfoView.uxml, который будет служить контейнером для изображений в элементе списка ImagesItemView.uxml.

<ui:UXML xmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
    <ui:VisualElement name="ElementsContainer" usage-hints="MaskContainer" class="container">
        <BindableImage name="BindableImage" binding-image-path="Image" binding-is-visible-path="IsVisible" usage-hints="DynamicTransform" class="image image--animation image--init-position" />
        <ui:VisualElement name="ImageInfoContainer" usage-hints="GroupTransform" class="image-info__container">
            <uitk:BindableLabel name="AuthorLabel" binding-text-path="Author" class="image-info__label image-info__author-label" />
            <ui:VisualElement name="ImageIdPanel" style="flex-direction: row; align-items: center;">
                <uitk:BindableLabel name="ImageIdTitleLabel" text="Id -" class="image-info__label image-info__id-label" style="margin-right: 0;" />
                <uitk:BindableLabel name="ImageIdValueLabel" binding-text-path="Id" class="image-info__label image-info__id-label" />
            </ui:VisualElement>
        </ui:VisualElement>
    </ui:VisualElement>
</ui:UXML>

Компонент BindableImage является контейнером для изображения. Он будет плавно показываться и скрываться при изменении свойства IsVisible.

[BindableElement]
public partial class BindableImage : AnimatedImage
{
    [BindableProperty("Image")]
    private IReadOnlyProperty<Texture2D> _imageProperty;

    [BindableProperty("IsVisible")]
    private IReadOnlyProperty<bool> _isVisibleProperty;

    partial void AfterSetBindingContext(IBindingContext context, IObjectProvider objectProvider)
    {
        SetImage(_imageProperty?.Value);
        SetVisibility(_isVisibleProperty?.Value ?? false);
    }

    partial void AfterResetBindingContext(IObjectProvider objectProvider)
    {
        SetImage(default);
        SetVisibility(false);
    }

    partial void OnImagePropertyValueChanged(Texture2D value)
    {
        SetImage(value);
    }

    partial void OnIsVisiblePropertyValueChanged(bool value)
    {
        SetVisibility(value);
    }

    private void SetVisibility(bool isVisible)
    {
        if (isVisible)
        {
            AnimateIn();
        }
        else
        {
            AnimateOut();
        }
    }
}

[UxmlElement]
public partial class AnimatedImage : VisualElement
{
    private const string ImageInitPositionClassName = "image--init-position";

    protected void SetImage(Texture2D image)
    {
        style.backgroundImage = image;
    }

    protected void AnimateIn()
    {
        RemoveFromClassList(ImageInitPositionClassName);
    }

    protected void AnimateOut()
    {
        AddToClassList(ImageInitPositionClassName);
    }
}
BindableImage.Uxml.g.cs
// <auto-generated/>
#pragma warning disable
#nullable enable

...

namespace BindableUIElements
{
    partial class BindableImage
    {
        [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
		[ExcludeFromCodeCoverage]
		private string BindingImagePath { get; set; }

		[GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
		[ExcludeFromCodeCoverage]
		private string BindingIsVisiblePath { get; set; }

        [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
        public new class UxmlFactory : UxmlFactory<BindableImage, UxmlTraits>
        {
        }

        [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
        public new class UxmlTraits : AnimatedImage.UxmlTraits
        {
            [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
            private readonly UxmlStringAttributeDescription _bindingImagePath = new() 
                { name = "binding-image-path", defaultValue = "Image" };

			[GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
            private readonly UxmlStringAttributeDescription _bindingIsVisiblePath = new() 
                { name = "binding-is-visible-path", defaultValue = "IsVisible" };

            [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
            [ExcludeFromCodeCoverage]
            public override void Init(VisualElement visualElement, IUxmlAttributes bag, CreationContext context)
            {
                base.Init(visualElement, bag, context);

                var control = (BindableImage) visualElement;
                control.BindingImagePath = _bindingImagePath.GetValueFromBag(bag, context);
				control.BindingIsVisiblePath = _bindingIsVisiblePath.GetValueFromBag(bag, context);
            }
        }
    }
}

BindableImage.Bindings.g.cs
// <auto-generated/>
#pragma warning disable
#nullable enable

...

namespace BindableUIElements
{
    partial class BindableImage : IBindableElement
    {
        [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
		private PropertyBindingData? _imageBindingData;

		[GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
		private PropertyBindingData? _isVisibleBindingData;

        [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
        [ExcludeFromCodeCoverage]
        public void SetBindingContext(IBindingContext context, IObjectProvider objectProvider)
        {
            BeforeSetBindingContext(context, objectProvider);
            
            if (string.IsNullOrWhiteSpace(BindingImagePath) == false)
			{
				_imageBindingData ??= StringExtensions.ToPropertyBindingData(BindingImagePath!);
				_imageProperty = objectProvider.RentReadOnlyProperty<Texture2D>(context, _imageBindingData!);
				_imageProperty!.ValueChanged += OnImagePropertyValueChanged;
			}

			if (string.IsNullOrWhiteSpace(BindingIsVisiblePath) == false)
			{
				_isVisibleBindingData ??= StringExtensions.ToPropertyBindingData(BindingIsVisiblePath!);
				_isVisibleProperty = objectProvider.RentReadOnlyProperty<bool>(context, _isVisibleBindingData!);
				_isVisibleProperty!.ValueChanged += OnIsVisiblePropertyValueChanged;
			}
    
            AfterSetBindingContext(context, objectProvider);
        }

        [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
        [ExcludeFromCodeCoverage]
        public void ResetBindingContext(IObjectProvider objectProvider)
        {
            BeforeResetBindingContext(objectProvider);

            if (_imageProperty != null)
			{
				_imageProperty!.ValueChanged -= OnImagePropertyValueChanged;
				objectProvider.ReturnReadOnlyProperty(_imageProperty);
				_imageProperty = null;
			}

			if (_isVisibleProperty != null)
			{
				_isVisibleProperty!.ValueChanged -= OnIsVisiblePropertyValueChanged;
				objectProvider.ReturnReadOnlyProperty(_isVisibleProperty);
				_isVisibleProperty = null;
			}
            
            AfterResetBindingContext(objectProvider);
        }

        [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
		[ExcludeFromCodeCoverage]
		private void OnImagePropertyValueChanged(object sender, Texture2D value)
		{
			OnImagePropertyValueChanged(value);
		}

		[GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
		[ExcludeFromCodeCoverage]
		private void OnIsVisiblePropertyValueChanged(object sender, bool value)
		{
			OnIsVisiblePropertyValueChanged(value);
		}

        [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
        partial void BeforeSetBindingContext(IBindingContext context, IObjectProvider objectProvider);

        [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
        partial void AfterSetBindingContext(IBindingContext context, IObjectProvider objectProvider);

        [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
        partial void BeforeResetBindingContext(IObjectProvider objectProvider);

        [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
        partial void AfterResetBindingContext(IObjectProvider objectProvider);

        [GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
		partial void OnImagePropertyValueChanged(Texture2D value);

		[GeneratedCodeAttribute("UnityMvvmToolkit.Generator", "1.0.0.0")]
		partial void OnIsVisiblePropertyValueChanged(bool value);
    }
}

Ниже представлен кусок кода из файла стилей image-info-view.uss, который отвечает за анимацию компонента.

...

.image--animation {
    transition-property: translate;
    transition-duration: 1250ms;
    transition-timing-function: ease-out-cubic;
}

.image--init-position {
    translate: 0 256px;
}

Модель представления для ImageInfoView.uxml выглядит следующим образом:

public partial class ImageInfoViewModel : IImageInfoModel, IBindingContext
{
    private readonly Texture2D _invalidTexture = Texture2D.grayTexture;

    [WithObservableBackingField]
    public string Id
    {
        get => _id.Value;
        set => _id.Value = value;
    }

    [WithObservableBackingField]
    public string Author
    {
        get => _author.Value;
        set => _author.Value = value;
    }

    [WithObservableBackingField]
    public bool IsVisible
    {
        get => _isVisible.Value;
        set => _isVisible.Value = value;
    }

    [WithObservableBackingField]
    public Texture2D Image
    {
        get => _image.Value;
        set
        {
            if (_image.Value != _invalidTexture)
            {
                Object.Destroy(_image.Value);
            }

            _image.Value = value;
        }
    }

    public void ResetWithId(string imageId)
    {
        Id = imageId;
        Author = default;
        IsVisible = default;

        ReleaseImage();
    }

    public void SetInvalidState(string message)
    {
        Author = message;
        Image = _invalidTexture;
        IsVisible = true;
    }

    public void Dispose()
    {
        ReleaseImage();
    }

    private void ReleaseImage()
    {
        Image = null;
    }
}

Здесь стоит обратить внимание на сеттер у свойства Image. Для предотвращения утечки памяти мы должны уничтожить предыдущее изображение, так как это загруженный из сети неуправляемый ресурс.

Images Item

Далее нам необходимо создать элемент списка ImagesItemView.uxml, в котором будет два изображения.

<ui:UXML ...>
    <ui:Template name="ImageInfoView" src="..." />
    <ui:VisualElement name="ImagesContainer" usage-hints="GroupTransform" class="image-item">
        <ImageInfoBindingContextProvider binding-context-path="LeftImageInfo" class="image-item__image-container">
            <ui:Instance template="ImageInfoView" name="LeftImageInfoContainer" />
        </ImageInfoBindingContextProvider>
        <ImageInfoBindingContextProvider binding-context-path="RightImageInfo" class="image-item__image-container">
            <ui:Instance template="ImageInfoView" name="RightImageInfoContainer" />
        </ImageInfoBindingContextProvider>
    </ui:VisualElement>
</ui:UXML>

Здесь стоит остановится на компоненте ImageInfoBindingContextProvider. Из названия понятно, что основная его задача – предоставить кастомный BindingContext для всех дочерних элеметнов. Т.е. в нашем случае для шаблона LeftImageInfoContainer в качестве BindingContext'а будет задано свойство LeftImageInfo, а для шаблона RightImageInfoContainer свойство RightImageInfo.

Ниже представлена реализация компонента ImageInfoBindingContextProvider:

[UxmlElement]
public partial class ImageInfoBindingContextProvider : BindingContextProvider<ImageInfoViewModel>
{
}

Конечно, мы могли бы использовать готовый компонент BindingContextProvider для этих целей, но в таком случае пришлось бы аллоцировать столько PropertyCastWrapper'ов, сколько элементов помещается у нас на экране. Да, под капотом UnityMvvmToolkit использует пулы, и эти элементы создались бы единожды и дальше переиспользовались. Но зачем аллоцировать, если можно не аллоцировать?

Модель представления для ImagesItemView.uxml выглядит следующим образом:

public class ImagesItemViewModel : ICollectionItem, IDisposable
{
    private readonly IImagesItemModel _model;

    ...

    public ImagesItemViewModel(int id, IImagesItemModel model)
    {
        ...

        LeftImageInfo = new ReadOnlyProperty((ImageInfoViewModel) model.LeftImageInfo);
        RightImageInfo = new ReadOnlyProperty((ImageInfoViewModel) model.RightImageInfo);
    }

    public IReadOnlyProperty<ImageInfoViewModel> LeftImageInfo { get; }
    public IReadOnlyProperty<ImageInfoViewModel> RightImageInfo { get; }

    public void StartImagesLoading(int itemIndex)
    {
        if (TryUpdateItemIndex(itemIndex))
        {
            StopPreviousImagesLoading();

            if (_isLoading == false)
            {
                StartImagesLoadingAsync(itemIndex).Forget();
            }
        }
    }

    private bool TryUpdateItemIndex(int itemIndex)
    {
        if (_itemIndex == itemIndex)
        {
            return false;
        }

        _itemIndex = itemIndex;
        return true;
    }

    private void StopPreviousImagesLoading()
    {
        _cancellationTokenSource?.Cancel();
    }

    private async UniTaskVoid StartImagesLoadingAsync(int itemIndex)
    {
        _isLoading = true;

        while (_isLoading)
        {
            _cancellationTokenSource = new CancellationTokenSource();

            try
            {
                await _model
                    .Configure(itemIndex)
                    .LoadImagesAsync(_cancellationTokenSource.Token)
                    .SuppressCancellationThrow();

                if (IsItemIndexValid(itemIndex, out var newItemIndex))
                {
                    _isLoading = false;
                }
                else
                {
                    itemIndex = newItemIndex;
                }
            }
            finally
            {
                _cancellationTokenSource?.Dispose();
                _cancellationTokenSource = null;
            }
        }
    }

    private bool IsItemIndexValid(int itemIndex, out int newItemIndex)
    {
        newItemIndex = _itemIndex;
        return itemIndex == _itemIndex && _isDisposed == false;
    }
}

Логика работы тут довольно простая. В методе StartImagesLoading мы проверяем, поменялся ли индекс элемента в списке. Помним, что у нас элементы переиспользуются, поэтому если индекс поменялся, то необходимо остановить загрузку изображений для предыдущего индекса и начать загрузку изображений для нового. Но чтобы каждый раз не стартовать асинхронную задачу для загрузки новых изображений, мы держим её запущенной до тех пор, пока у нас индекс элемента для загруженных изображений не совпадёт с текущим индексом элемента или пока не вызовется Dispose у класса. Т.е. если мы начали загрузку изображений для элемента 5 и после загрузки у нас индекс у элемента остался 5, то мы выходим из цикла while, иначе начинаем загрузку изображений для нового индекса если, конечно, _isDisposed == false.

ImagesItemModel
public class ImagesItemModel : IImagesItemModel
{
    ...

    public IImageInfoModel LeftImageInfo { get; private set; }
    public IImageInfoModel RightImageInfo { get; private set; }

    public IImagesItemModel Configure(int itemIndex)
    {
        var imagesId = _imagesIdProvider.GetImagesId(itemIndex);

        LeftImageInfo.ResetWithId(imagesId.LeftImageId);
        RightImageInfo.ResetWithId(imagesId.RightImageId);
        
        return this;
    }

    public async UniTask LoadImagesAsync(CancellationToken cancellationToken)
    {
        await UniTask.WhenAll(
            PopulateImageInfoAsync(LeftImageInfo, cancellationToken),
            PopulateImageInfoAsync(RightImageInfo, cancellationToken));
    }

    private async UniTask PopulateImageInfoAsync(IImageInfoModel imageInfo, CancellationToken cancellationToken)
    {
        var result = await _imageDataProvider.DownloadImageInfoAsync(imageInfo.Id, cancellationToken);

        if (result.IsFailure)
        {
            imageInfo.SetInvalidState(result.Error);
            return;
        }

        JsonConvert.PopulateObject(result.Value.InfoJson, imageInfo);

        imageInfo.Image = result.Value.Image;
        imageInfo.IsVisible = true;
    }
}

ImageDataProvider
public sealed class ImageDataProvider : IImageDataProvider, IDisposable
{
    ...

    public async UniTask<Result<(string InfoJson, Texture2D Image)>> DownloadImageInfoAsync(string imageId,
        CancellationToken cancellationToken = default)
    {
        try
        {
            var urls = GetImageUrls(imageId);

            var infoJson = await DownloadInfoAsync(urls.InfoUrl, cancellationToken);
            if (infoJson is null)
            {
                return Result.Failure<(string, Texture2D)>(ImageInfoNotFound);
            }

            var image = await DownloadImageAsync(urls.ImageUrl, cancellationToken);

            return image
                ? Result.Success<(string, Texture2D)>((infoJson, image))
                : Result.Failure<(string, Texture2D)>(ImageNotFound);
        }
        catch (UnityWebRequestException exception)
        {
            return Result.Failure<(string, Texture2D)>(exception.ResponseCode == 404
                ? ImageNotFound
                : exception.Message);
        }
    }

    private static async UniTask<string> DownloadInfoAsync(string infoUrl, CancellationToken cancellationToken)
    {
        using var unityWebRequest = UnityWebRequest.Get(infoUrl);

        await unityWebRequest
            .SendWebRequest()
            .WithCancellation(cancellationToken);

        return unityWebRequest.result == UnityWebRequest.Result.Success
            ? Encoding.UTF8.GetString(unityWebRequest.downloadHandler.data)
            : null;
    }

    private static async UniTask<Texture2D> DownloadImageAsync(string imageUrl, CancellationToken cancellationToken)
    {
        using var unityWebRequest = UnityWebRequestTexture.GetTexture(imageUrl);

        await unityWebRequest
            .SendWebRequest()
            .WithCancellation(cancellationToken);

        return unityWebRequest.result == UnityWebRequest.Result.Success
            ? DownloadHandlerTexture.GetContent(unityWebRequest)
            : null;
    }
}

Images Viewer

Ну и, наконец, давайте создадим основную модель представления ImagesViewerViewModel.

public class ImagesViewerViewModel : IBindingContext, IDisposable
{
    private const int MinItemsThreshold = 5;
    private const int VirtualItemsIncrement = 5;

    public ImagesViewerViewModel(IImagesViewerModel model)
    {
        if (model.ImagesItems.Count < MinItemsThreshold)
        {
            throw new InvalidOperationException(
                $"The number of reusable elements cannot be less than {MinItemsThreshold}.");
        }

        var reusableImageItems =
            new ReusableItemsList<ImagesItemViewModel>(GetImagesItemViewModels(model.ImagesItems));

        ImageItems = new ReadOnlyProperty<ReusableItemsList<ImagesItemViewModel>>(reusableImageItems);

        MaxBindIndex = new Property<int>();
        MaxBindIndex.ValueChanged += OnMaxBindIndexChanged;
    }

    public IProperty<int> MaxBindIndex { get; }
    public IReadOnlyProperty<ReusableItemsList<ImagesItemViewModel>> ImageItems { get; }

    public void Dispose()
    {
        foreach (var imagesItemViewModel in ImageItems.Value)
        {
            imagesItemViewModel.Dispose();
        }

        ImageItems.Value.Clear();
        MaxBindIndex.ValueChanged -= OnMaxBindIndexChanged;
    }

    private void OnMaxBindIndexChanged(object sender, int maxIndex)
    {
        if (maxIndex == ImageItems.Value.Count - 1)
        {
            ImageItems.Value.AddVirtualItems(VirtualItemsIncrement);
        }
    }

    private static IEnumerable<ImagesItemViewModel> GetImagesItemViewModels(
        IReadOnlyList<IImagesItemModel> imagesItems)
    {
        for (var i = 0; i < imagesItems.Count; i++)
        {
            yield return new ImagesItemViewModel(i, imagesItems[i]);
        }
    }
}

Самое интересное здесь происходит в методе OnMaxBindIndexChanged, в котором, если пользователь долистал до последнего элемента в списке, мы добавляем ещё 5 виртуальных элементов в коллекцию.

Модель ImagesViewerModel:

public sealed class ImagesViewerModel : IImagesViewerModel, IDisposable
{
    private const int InMemoryCacheItemsCount = 5;

    ...

    public IReadOnlyList<IImagesItemModel> ImagesItems => _imagesItems;

    private void CreateImagesItems(IImagesIdProvider imagesIdProvider, IImageDataProvider imageDataProvider)
    {
        _imagesItems = new IImagesItemModel[InMemoryCacheItemsCount];

        for (var i = 0; i < InMemoryCacheItemsCount; i++)
        {
            _imagesItems[i] = new ImagesItemModel(imagesIdProvider, imageDataProvider);
        }
    }
}

Константа InMemoryCacheItemsCount отвечает за то, сколько уникальных элементов будет в нашем списке с изображениями. В данном случае это 5 элементов. Следовательно, в памяти у нас одновременно будет храниться только 10 изображений по 2 на каждый элемент списка.

Далее создадим ImagesViewerView.cs базовым классом для которого будет DocumentView<ImagesViewerViewModel>, и установим ImagesItemView.uxml в поле _imagesItemTemplate, чтобы наш список понял, какой шаблон необходимо использовать для элементов типа ImagesItemViewModel.

public class ImagesViewerView : DocumentView<ImagesViewerViewModel>
{
    [SerializeField] private VisualTreeAsset _imagesItemTemplate;

    ...

    protected override IReadOnlyDictionary<Type, object> GetCollectionItemTemplates()
    {
        return new Dictionary<Type, object>
        {
            { typeof(ImagesItemViewModel), _imagesItemTemplate }
        };
    }
}

DocumentView<TBindingContext> является тем самым классом, который обеспечит привязку данных между View и ViewModel.

Осталось только собрать нашу View ImagesViewerView.uxml и не забыть у BindableImageListView в качестве binding-items-source-path указать ImageItems.

<ui:UXML xmlns:uitk="UnityMvvmToolkit.UITK.BindableUIElements" ...>
    <Style src="project://database/Assets/UI%20Toolkit/Styles/root.uss?fileID=7433441132597879392&amp;guid=e3fa7fbf0e9b3fb48916d2e77209f1ca&amp;type=3#root" />
    <Style src="project://database/Assets/UI%20Toolkit/Styles/main-view.uss?fileID=7433441132597879392&amp;guid=0532dc2e51322554a9b731042cfb65ae&amp;type=3#main-view" />
    <SafeAreaContainer usage-hints="GroupTransform">
        <ui:VisualElement name="Header" usage-hints="GroupTransform" class="main-view__header">
            <uitk:BindableLabel text="Images" class="main-view__header-label main-view__header-label--left" />
            <uitk:BindableLabel text=" Viewer" class="main-view__header-label main-view__header-label--right" />
        </ui:VisualElement>
        <ui:VisualElement name="Content" usage-hints="GroupTransform" class="main-view__content">
            <BindableImageListView name="ImagesListView" binding-items-source-path="ImageItems" />
        </ui:VisualElement>
    </SafeAreaContainer>
</ui:UXML>

P.S. Обратите внимание, что мы явно не указываем MaxBindIndex в качестве binding-max-bind-index-path у нашего BindableImageListView. Всё потому, что мы при создании компонента сразу в атрибуте свойства _maxBindIndexProperty указали ему значение по умолчанию, которое совпадает с названием свойства из ViewModel.

На этом наше приложение завершено. Скачать версию для Android можно здесь, а доступ к исходникам и к UnityMvvmToolkit.Generator можно получить здесь.

А какой архитектурный шаблон используете Вы для построения пользовательского интерфейса? MVC, MVP, или MVVM?

Комментарии (0)