Search code examples
c#wpflistbox

How can I implement a carousel of images in WPF where the selected item is always the first one?


I am creating a WPF application to act as a front end for a video games library and I'm attempting to mimic the Netflix UI. One of the features in this application is to cycle through images of games to select which game you want to play.

The desired behavior is different than the behavior when arrowing through the items in a ListBox: when you arrow through items in a ListBox, your selection moves up and down. The behavior I'm looking to implement is that as you arrow through the items, the selected item is always at the first position and the items are cycling across the selector. The term for this would be a carousel where the selected item is at index 0.

I've implemented this poorly and to give some context, here's a picture of how my interface currently looks: My current implementation

To achieve this, I believe what I should do is extend the StackPanel class or maybe implement my own Panel. But details on custom panels are a bit complicated and hard to come by. I want to show what I've done to this point to get this working but I'm very unhappy with these implementations and I'd like to get some advice on what direction I should go for a proper implementation.

Here are some details on what I've tried.

The screenshot above is a result of a GameList class that I created which implements INotifyPropertyChanged and includes properties for 15 different games.

    private GameMatch game0;
    public GameMatch Game0
    {
        get { return game0; }
        set
        {
            if (game0 != value)
            {
                game0 = value;
                PropertyChanged(this, new PropertyChangedEventArgs("Game0"));
            }
        }
    }

    private GameMatch game1;
    public GameMatch Game1
    {
        get { return game1; }
        set
        {
            if (game1 != value)
            {
                game1 = value;
                PropertyChanged(this, new PropertyChangedEventArgs("Game1"));
            }
        }
    }

    // identical code for games 2-10

    private GameMatch game11;
    public GameMatch Game11
    {
        get { return game11; }
        set
        {
            if (game11 != value)
            {
                game11 = value;
                PropertyChanged(this, new PropertyChangedEventArgs("Game11"));
            }
        }
    }

    private GameMatch game12;
    public GameMatch Game12
    {
        get { return game12; }
        set
        {
            if (game12 != value)
            {
                game12 = value;
                PropertyChanged(this, new PropertyChangedEventArgs("Game12"));
            }
        }
    }

I've laid the images out in my XAML and added enough so that they will run off the edge of the screen:

    <StackPanel Name="GameImages" Orientation="Horizontal">
    <Border BorderThickness="2" BorderBrush="AntiqueWhite">
        <Image Name="Image_Game1" Source="{Binding CurrentGameList.Game1.FrontImage}"/>
    </Border>

    <Image Source="{Binding CurrentGameList.Game2.FrontImage}"/>

    <!-- identical images for games 3-10 -->

    <Image Source="{Binding CurrentGameList.Game11.FrontImage}" />

    <Image Source="{Binding CurrentGameList.Game12.FrontImage}" />
</StackPanel>

I implemented a ListCycle class which can take any arbitrary list and a count of items that you want to cycle. In case it helps, here's the code for the ListCycle. It takes care of cycling the lists by tracking the index of items in list that should be displayed on screen in a given position.

public class ListCycle<T>
{
    // list of games or whatever you want
    public List<T> GenericList { get; set; }

    // indexes currently available to display
    // will cycle around the size of the generic list
    public int[] indices;

    public ListCycle(List<T> genericList, int activeCycleCount)
    {
        GenericList = genericList;
        indices = new int[activeCycleCount];
        InitializeIndices();
    }

    private void InitializeIndices()
    {
        if (GenericList != null)
        {
            int lastIndex = -1;
            for (int i = 0; i < indices.Length; i++)
            {
                indices[i] = GetNextIndex(lastIndex);
                lastIndex = indices[i];
            }
        }
    }

    private int GetNextIndex(int currentIndex)
    {
        currentIndex += 1;
        if (currentIndex == GenericList.Count)
        {
            currentIndex = 0;
        }
        return currentIndex;
    }

    private int GetPreviousIndex(int currentIndex)
    {
        currentIndex -= 1;
        if (currentIndex == -1)
        {
            currentIndex = GenericList.Count - 1;
        }
        return currentIndex;
    }

    public int GetIndexValue(int index)
    {
        return indices[index];
    }

    public T GetItem(int index)
    {
        return GenericList[indices[index]];
    }

    public void CycleForward()
    {
        for (int i = 0; i < indices.Length; i++)
        {
            if (i + 1 < indices.Length - 1)
            {
                indices[i] = indices[i + 1];
            }
            else
            {
                indices[i] = GetNextIndex(indices[i]);
            }
        }
    }

    public void CycleBackward()
    {
        for (int i = indices.Length - 1; i >= 0; i--)
        {
            if(i - 1 >= 0)
            {
                indices[i] = indices[i - 1];
            }
            else
            {
                indices[i] = GetPreviousIndex(indices[i]);
            }
        }
    }
}

So when you press right, I cycle forward and reset the game images. When you press left, I cycle backward and reset the game images. The RefreshGames method takes care of updating all of those game properties in my game list.

private void RefreshGames()
{
        Game0 = gameCycle.GetItem(0);
        Game1 = gameCycle.GetItem(1);
        Game2 = gameCycle.GetItem(2);
        Game3 = gameCycle.GetItem(3);
        Game4 = gameCycle.GetItem(4);
        Game5 = gameCycle.GetItem(5);
        Game6 = gameCycle.GetItem(6);
        Game7 = gameCycle.GetItem(7);
        Game8 = gameCycle.GetItem(8);
        Game9 = gameCycle.GetItem(9);
        Game10 = gameCycle.GetItem(10);
        Game11 = gameCycle.GetItem(11);
        Game12 = gameCycle.GetItem(12);
 }

This approach works but it doesn't work well. It's not dynamic, it doesn't scale well and it doesn't perform all that well. Arrowing through images one at a time performs just fine but trying to quickly move through them, is a bit slow and feels clunky. It's not a very good user experience.

I tried a second approach, using a listbox bound the to my list of games. And to cycle the games to the left, I would remove the first item from my list of games and insert it at the end of the list. To go to the right, I would remove the item at the end of the list and insert it at index 0. This also worked but it didn't perform very well either.

So I'm looking for suggestions on a better way to implement this that would give better performance (smoother scrolling) and be more dynamic (i.e. this approach may not work well on an ultrawide monitor - 12 games may not be enough depending on the widths of the images). I'm not looking for anyone to solve it for me but point me in the right direction as I'm very new to WPF.

My feeling is that I should be extending the stack panel class and changing the way you cycle through the items or maybe creating my own panel. Can anyone confirm if this is the best approach and if so point me to some good resources to help me understand how to create a custom panel that changes the way navigation is done? I've been reading articles on creating custom panels to try to get my head around that process.

Being new to WPF, I want to make sure I'm not going down a rabbit hole or trying to reinvent a wheel that already exists. So the question is whether a custom panel is the right approach to solving this problem?


Solution

  • I believe what I should do is extend the StackPanel class

    WPF encourages composition of existing Controls over inheritance; in your case inheriting the StackPanel looks too complicated for your purpose when you could achieve the same with your second approach:

    I would remove the first item from my list of games and insert it at the end of the list

    This indeed looks more like idiomatic WPF, especially if you try to follow the MVVM design pattern.

    or maybe creating my own panel

    This is not an easy step especially if you're new to WPF but that would be very interesting for you. That could be a way to go, especially if you internally rely on a StackPanel (composition) instead of inheriting from it.

    Example implementation with an ItemsControl

    I will use an ItemsControl which can display a collection of data for you (in your case, you have some GameMatch).

    First define the data behind the interface, ie a collection of GameMatch. Let's give each GameMatch a name and a variable IsSelected which tells if the game is selected (ie in first position). I'm not showing the INotifyPropertyChanged implementation but it should be there for both properties.

    public class GameMatch : INotifyPropertyChanged {
    
        public string Name { get => ...; set => ...; }
    
        public bool IsSelected { get => ...; set => ...;  }
    }
    

    Your carousel interface is interested in a collection of GameMatch, so let's create an object to model this. Our graphical interface is gonna bind to the Items property to display the collection of games. It is also gonna bind to the two commands that are implemented such as to shift the list to the left or to the right. You can use the RelayCommand to create commands. In a nutshell, a Command is simply an action that gets executed and that you can easily refer to from your interface.

    public class GameCollection {
    
        // Moves selection to next game
        public ICommand SelectNextCommand { get; }
    
        // Moves selection to previous game
        public ICommand SelectPreviousCommand { get; }
        
        public ObservableCollection<GameMatch> Items { get; } = new ObservableCollection<GameMatch> {
            new GameMatch() { Name = "Game1" },
            new GameMatch() { Name = "Game2" },
            new GameMatch() { Name = "Game3" },
            new GameMatch() { Name = "Game4" },
            new GameMatch() { Name = "Game5" },
        };
    
        public GameCollection() {
            SelectNextCommand = new RelayCommand(() => ShiftLeft());
            SelectPreviousCommand = new RelayCommand(() => ShiftRight());
            SelectFirstItem();
        }
    
        private void SelectFirstItem() {
            foreach (var item in Items) {
                item.IsSelected = item == Items[0];
            }
        }
    
        public void ShiftLeft() {
            // Moves the first game to the end
            var first = Items[0];
            Items.RemoveAt(0);
            Items.Add(first);
            SelectFirstItem();
        }
    
        private void ShiftRight() {
            // Moves the last game to the beginning
            var last = Items[Items.Count - 1];
            Items.RemoveAt(Items.Count - 1);
            Items.Insert(0, last);
            SelectFirstItem();
        }
    }
    

    The key here is the ObservableCollection class which will tell the view whenever it changes (for example, everytime we move items around inside it) so the view will update to reflect this.

    Then, the view (your XAML) should specify how to display the collection of games. We're gonna use an ItemsControl laying out items horizontally:

    <StackPanel>
        <ItemsControl ItemsSource="{Binding Items}">
            <ItemsControl.ItemsPanel>
                <ItemsPanelTemplate>
                    <StackPanel Orientation="Horizontal"/>
                </ItemsPanelTemplate>
            </ItemsControl.ItemsPanel>
            <ItemsControl.ItemTemplate>
                <DataTemplate>
                    <Border Margin="10" Background="Beige" BorderBrush="Black" Width="150" Height="50">
                        <Border.Style>
                            <Style TargetType="Border">
                                <Setter Property="BorderThickness" Value="1" />
                                <Style.Triggers>
                                    <DataTrigger Binding="{Binding IsSelected}" Value="true">
                                        <Setter Property="BorderThickness" Value="5" />
                                    </DataTrigger>
                                </Style.Triggers>
                            </Style>
                        </Border.Style>
                        <TextBlock Text="{Binding Name}"/>
                    </Border>
                </DataTemplate>
            </ItemsControl.ItemTemplate>
        </ItemsControl>
        <StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
            <Button Content="Previous" Command="{Binding SelectPreviousCommand}"/>
            <Button Content="Next" Command="{Binding SelectNextCommand}"/>
        </StackPanel>
    </StackPanel>
    

    Notice the ItemsControl ItemsSource="{Binding Items}" which tells the ItemsControl to display all the objects in the Items property. The ItemsControl.ItemsPanel part tells to lay them out in an horizontal StackPanel. The ItemsControl.ItemTemplate part explains how each game should be displayed, and the DataTrigger within tells WPF to increase the border thickness for the selected item. Finally, the StackPanel at the bottom displays two Button which call SelectPreviousCommand and SelectLeftCommand in our GameCollection.

    Finally, you should set the DataContext of the whole thing to a new GameCollection:

    public partial class MainWindow : Window {
        public MainWindow() {
            InitializeComponent();
            DataContext = new GameCollection();
        }
    }
    

    From there you can customize the UI as you'd like.

    final result

    Animations and smooth scrolling

    That is a whole other topic but you could for example trigger a translation animation of all your items when clicking one of the buttons.