Search code examples
c#mvvmuser-controlswinui-3windows-app-sdk

Create a UserControl with a ViewModel


I want to create a UserControl in WinUI 3 called ImageViewer that will show, traverse, add and delete photos.

My UserControl contains the following properties:

  • ICollection<Product_PhotoModel Photos used for accessing the picture models
  • DependencyProperty PhotosProperty used for creating a Property that can be binded from XAML like this: Note: The name of the XAML property does not have to be the same as the collection property.
  • int CurrentPhotoIndex for keeping track of the current photo shown and stopping the user from accessing bad indexes (trying to access a photo after the last one, or before the first one)
  • string CurrentPhotoDescription for keeping track which of the path of the current photo
  • ImageViewerViewModel ViewModel To contain the data and logic

Since ImageViewer is a UserControl it inherits from class UserControl. For the Photos to be able to change when pressing the Previous/Next/Add/Delete buttons the Photos Collection and Current* Properties have to be able to notify the UI that something has changed (i.e. the current photo was deleted, thus show the last one). However, my understanding is that a class must inherit from ObservableRecipient and thus use the OnPropertyChanged method on its data (e.g. Photos, CurrentPhotoIndex). My thinking is that the Photos collection must not be in the code-behind (ImageViewer.xaml.cs) but in the ViewModel (ImageViewerViewModel) and then I will be able to traverse/add/delete photos.

My problem is that in the example (from which I took inspiration from) did not use a ViewModel and I don't know how to set the DependencyProperty data to be directly from the ViewModel.

My questions:

  1. Is it a good idea to have a ViewModel in a UserControl?
  2. If the use of a ViewModel is applicable and good practice/design, do I need to keep a collection of the models in the code-behind and the ViewModel, synchronizing them when appropriate or just in the ViewModel?
  3. If the use of a ViewModel is not a good idea (hard to implement, there is another easier or better way) how does the Photos collection become Observable? Note: that I initialize it as a ObservableCollection<Product_PhotoModel> but I can't see the pictures correctly.

I hope that my question is well posed. And if anyone needs some more information, I will be more than happy to provide it.

ImageViewer.xaml

<!-- Copyright (c) Microsoft Corporation and Contributors. -->
<!-- Licensed under the MIT License. -->

<UserControl
    x:Class="MyApp.Desktop.UserControls.ImageViewer"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:MyApp.Desktop.UserControls"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:dxe="using:DevExpress.WinUI.Editors"
    mc:Ignorable="d">

    <Grid Padding="12">
        <Grid.RowDefinitions>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>

        <StackPanel Orientation="Horizontal"
                    Grid.Row="0">
            <Grid>
                <Image Source="{x:Bind CurrentPhotoPath, Mode=TwoWay}"
                       Stretch="Uniform"/>

                <Button Click="BtnPrevPhoto_Click"
                VerticalAlignment="Center" HorizontalAlignment="Left"
                Style="{StaticResource PreviousButtonStyle}"/>

                <Button Click="BtnNextPhoto_Click"
                VerticalAlignment="Center" HorizontalAlignment="Right"
                Style="{StaticResource NextButtonStyle}"/>
            </Grid>

            <StackPanel Orientation="Vertical"
                        VerticalAlignment="Center"
                        Margin="{StaticResource SmallLeftMargin}"
                        Grid.Column="1">
                <Button Click="BtnAddPhoto_Click"
                        Margin="{StaticResource XXSmallBottomMargin}">
                    <FontIcon Glyph="&#xe109;" Style="{StaticResource SmallFontIconStyle}"/>
                </Button>
                
                <Button Click="BtnDeletePhoto_Click"
                        Margin="{StaticResource XXSmallTopMargin}">
                    <FontIcon Glyph="&#xe107;" Style="{StaticResource SmallFontIconStyle}"/>
                </Button>
            </StackPanel>
        </StackPanel>

        <dxe:TextEdit x:Uid="/Products/TxtPhotoDescription"
                      Text="{x:Bind CurrentPhotoDescription, Mode=TwoWay}"
                      IsReadOnly="{x:Bind IsReadOnly, Mode=OneWay}"
                      Style="{StaticResource FormSmallTextEditStyle}"
                      Margin="{StaticResource SmallTopMargin}"
                      HorizontalAlignment="Stretch"
                      Grid.Row="1"/>
        
    </Grid>
</UserControl>

ImageViewer.xaml.cs

// Copyright (c) Microsoft Corporation and Contributors.
// Licensed under the MIT License.

using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using DevExpress.Mvvm.Native;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using MyApp.Desktop.Helpers;
using MyApp.Desktop.ViewModels.UserControls;
using MyApp.Models.FileStorage;
using MyApp.Models.Products;

namespace ThemelioApp.Desktop.UserControls;

public sealed partial class ImageViewer : UserControl
{
    public static readonly DependencyProperty PhotosProperty = DependencyProperty.Register(
    "Photos", typeof(ICollection<Product_PhotoModel>), typeof(ImageViewer), new PropertyMetadata(null));

    public ICollection<Product_PhotoModel> Photos
    {
        get => (ICollection<Product_PhotoModel>)GetValue(PhotosProperty);
        set => SetValue(PhotosProperty, value);
    }

    public static readonly DependencyProperty PhotosToDeleteProperty = DependencyProperty.Register(
"PhotosToDelete", typeof(ICollection<long>), typeof(ImageViewer), new PropertyMetadata(null));

    public ICollection<long> PhotosToDelete
    {
        get => (ICollection<long>)GetValue(PhotosToDeleteProperty);
        set => SetValue(PhotosToDeleteProperty, value);
    }

    public static readonly DependencyProperty IsReadOnlyProperty = DependencyProperty.Register(
    "IsReadOnlyProperty", typeof(bool), typeof(ImageViewer), new PropertyMetadata(null));

    public bool IsReadOnly
    {
        get => (bool)GetValue(IsReadOnlyProperty);
        set => SetValue(IsReadOnlyProperty, value);
    }

    public int CurrentPhotoIndex
    {
        get; set;
    }

    private readonly string _noPhotoPath;

    public string CurrentPhotoPath
    {
        get; set;
    }

    public string CurrentPhotoDescription
    {
        get; set;
    }

    public ImageViewerViewModel ViewModel
    {
        get;
    }

    public ImageViewer()
    {
        InitializeComponent();

        ViewModel = App.GetService<ImageViewerViewModel>();

        Photos = new ObservableCollection<Product_PhotoModel>();

        PhotosToDelete = new ObservableCollection<long>();

        CurrentPhotoIndex = 0;

        _noPhotoPath = new Uri("ms-appx:///assets/Images/NoImage128x128.png").LocalPath;

        CurrentPhotoPath = Photos.Count == 0 ? _noPhotoPath : Photos.FirstOrDefault().Photo.LocalFilePath;

        CurrentPhotoDescription = "";
    }

    private async void BtnAddPhoto_Click(object sender, RoutedEventArgs e)
    {
        var file = await FileStorageHelper.PickFile(FileStorageHelper.PickFileType.image, true);
        if (file == null)
        {
            return;
        }

        AddPhotoLocal(file.Path);
    }

    private async void BtnDeletePhoto_Click(object sender, RoutedEventArgs e)
    {
        RemovePhotoLocal();
    }

    private void BtnNextPhoto_Click(object sender, RoutedEventArgs e)
    {
        Next();
    }

    private void BtnPrevPhoto_Click(object sender, RoutedEventArgs e)
    {
        Previous();
    }

    public void Next()
    {
        CurrentPhotoIndex++;
        UpdateCurrentPhoto();
    }

    public void Previous()
    {
        CurrentPhotoIndex--;
        UpdateCurrentPhoto();
    }

    public void AddPhotoLocal(string path)
    {
        Photos.Add(
            new Product_PhotoModel()
            {
                Photo = new FileStorageDetailedModel()
                {
                    Description = "",
                    LocalFilePath = path
                }
            });
    }

    public void RemovePhotoLocal()
    {
        PhotosToDelete ??= new List<long>();

        var toDelete = Photos.ElementAt(CurrentPhotoIndex);
        if (toDelete.Id > 0)
        {
            PhotosToDelete.Add(toDelete.Photo.Id);
        }

        Photos.Remove(toDelete);

        UpdateCurrentPhoto();
    }

    public void UpdateCurrentPhoto()
    {
        if (CurrentPhotoIndex > Photos?.Count - 1)
        {
            CurrentPhotoIndex = (int)(Photos?.Count - 1);
        }
        else if (CurrentPhotoIndex < 0)
        {
            CurrentPhotoIndex = 0;
        }

        CurrentPhotoPath = Photos.ElementAt(CurrentPhotoIndex).Photo.LocalFilePath;
        CurrentPhotoDescription = Photos.ElementAt(CurrentPhotoIndex).Photo.Description;
    }
}

ImageViewerViewModel.cs (currently empty as I couldn't get it to work)

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.UI.Xaml;
using MyApp.Desktop.UserControls;
using MyApp.Models.FileStorage;
using MyApp.Models.Products;

namespace MyApp.Desktop.ViewModels.UserControls;

public class ImageViewerViewModel : ObservableRecipient
{

    public ImageViewerViewModel()
    {
    }

}

Solution

  • Inside your UserControl, you can just x:Name your Image control so you can change what image to show from code-behind.

    And, in my opinion, a UserControl should be just its *.xaml and *.xaml.cs. And should be reusable and available for ViewModels.

    You also need to consider what is the responsibility of your ImageViewer control.

    For example:

    • Just an image viewer
      Expose CurrentImage, PreviousCommand, NextCommand, AddCommand, RemoveCommand, etc... as DependencyProperties and let a ViewModel do all the work.

    • Just image navigation
      Expose ItemsSource and do navigation in code-behind, and expose AddCommand, RemoveCommand and let a ViewModel do add/remove from the ItemsSource collection in the ViewModel.

    • Image navigation and add/remove
      Expose Directory as DependencyProperty and do everthing (including image loading) in code-behind.