Overloading State Images In ListView Controls

In Winforms, the ListView control does not have intrinsic support for command buttons.  The traditional way to implement commands in ListView controls has been to use owner-drawn cell contents. If you just want to create a single, simple command for your line items, this may be more work than you were hoping for. 

This article will explain another way to implement a single command button in ListView controls.

If you set a ListView control to show CheckBoxes, you can also set the images for the checked and unchecked states, and it will send you events for when the state changes. You can use this to override the behavior and quickly implement a single command. Common use cases include:

  • A Delete button
  • A button to view details on an item
  • Playing and pausing a media file

To override the Check/Uncheck behavior, follow these steps:

  1. Use Details view for the ListView. 
  2. Switch on 'Checkboxes' for your ListView control
  3. Add events for ItemChecked and DoubleClick
  4. Initialize the StateImageList property with an ImageList, and add the images for your command buttons to this property. If you just have one command button image, add it to the image list twice.
  5. Implement your desired command action in the ItemChecked event.

That's all you have to do, but there are two important issues to be aware of:

  • During initialization, you will get an ItemChecked event for each item (row). The Checked property will be false.
  • Double-clicking an item checks or unchecks it. You get a separate event for DoubleClick, but unfortunately, you get it after the ItemChecked event.

The first issue is easy enough to handle. When the ItemChecked event fires during initialization, the Checked property is false, so if you only want to act when Checked is true, you don't have to do anything. If you do want to bypass handling these events during initialization, you can set a flag in the Load event of the form. Alternatively, you could add the events for your ListView control in the Load event.

The second issue is a little trickier. If you want the double-click action to do the same as the action button - say, view details - you may not have to do anything. If instead they are different, you have to code a workaround. For instance, if the action button deletes an item, but double-clicking an item shows details on it, you will definitely want to avoid deleting an item when the user double-clicks.

The way I have gotten around it is that I set a timer when I get the ItemChecked event. Then on the DoubleClick event, I stop the timer. If I get the timer event, I know the user checked or unchecked an item with the action button, not by double-clicking.

For the demo project, I downloaded some old Blues songs that are in the public domain and created a mini player around them. The action button overloading the state control mechanism implements Play and Pause for each item. I wanted to demonstrate different actions for 'Checked' vs double-clicking an item. Here is how it works in the mini-player: double-clicking an item will always play it from the beginning, whereas hitting the 'Play' button for an item that was previously paused will resume play at the stopped location for the track. Hitting 'Play' for a different item will play the new track from the beginning as well, just like double-click.

State Image List Demo Screenshot

Here is the code to initialize the state image list:

playlistLV.StateImageList = new ImageList();
playlistLV.StateImageList.Images.Add(LoadImage("Play"));
playlistLV.StateImageList.Images.Add(LoadImage("Pause"));

The code for the ItemChecked event:

private void OnChecked(object sender, ItemCheckedEventArgs e) {
    if (!m_initialized) return;
    //Set the item and the action to perform on the timer event.
    m_checkedItem = GetTrackItem(e.Item);
    if (e.Item.Checked) m_checkedAction = CheckedAction.Start;
    else m_checkedAction = CheckedAction.Pause;
    m_timer.Start();
}

I use the m_initialized flag to check whether to process the events. It starts out false and I set it to true in the Loaded event for the form. 

When I get the timer event, I stop the timer, then check for which state to execute for:

private void OnTick(object ? sender, EventArgs e) {
    m_timer.Stop();
    //Check action to perform
    switch (m_checkedAction) {
        case CheckedAction.Start:
            //If the item for the action is the current one, resume play.
            //Otherwise start from beginning.
            if (m_currentPlaying == m_checkedItem) Resume();
            else {
                //Stop first, which will reset overall state.
                Stop();
                m_currentPlaying = m_checkedItem;
                Play();
            }
            break;
        case CheckedAction.Pause:
            Pause();
            break;
        default:
            break;
    }
    //Reset the action variables.
    m_checkedItem = null;
    m_checkedAction = CheckedAction.None;
}

On the DoubleClick event, I stop the timer, then start playing the item selected from the beginning:

private void OnDoubleClick(object sender, EventArgs e) {
    //Stop the timer - nop if timer is not ticking.
    m_timer.Stop();
    //Always uncheck currently playing
    SilentlyUncheck(m_currentPlaying);
    m_currentPlaying = GetSelectedTrackItem();
    //Make sure currently playing is checked
    SilentlyCheck(m_currentPlaying);
    //Always start from beginning on double-click.
    Play();
}

Since I don't want to receive and process the ItemChecked event while I am processing any of the events, I use the "SilentlyCheck" and "SilentyUncheck" methods. They are defined thus:

internal void SilentlyCheck(TrackListItem ? item) {
    SilentlySetCheck(item, true);
}
internal void SilentlyUncheck(TrackListItem ? item) {
    SilentlySetCheck(item, false);
}
internal void SilentlySetCheck(TrackListItem ? item, bool check) {
    if (null == item) return;
    #pragma warning disable CS8622 // Nullability of reference types in type of parameter doesn't match the target delegate (possibly because of nullability attributes).
    playlistLV.ItemChecked -= OnChecked;
    item.Checked = check;
    playlistLV.ItemChecked += OnChecked;
    #pragma warning restore CS8622 // Nullability of reference types in type of parameter doesn't match the target delegate (possibly because of nullability attributes).
}

By removing the handler for the event before setting the property, the handler is not invoked, and it is safe to change. After the change, I reinstate the event handler.

I use the events on the player to handle when a track stops:

private void OnPlayStateChange(int newState) {
    if (null == m_currentPlaying) return;
    //When stopping, reset everything
    if (WMPPlayState.wmppsStopped == (WMPPlayState) newState) {
        SilentlyUncheck(m_currentPlaying);
        m_currentPlaying = null;
        tbNowPlaying.Text = "Stopped";
    }
    //When pausing, just reset the check
    if (WMPPlayState.wmppsPaused == (WMPPlayState) newState) SilentlyUncheck(m_currentPlaying);
}

Here I am relying on the events provided by the player to reset checks (state). If you have some other interaction that allows only one item to be 'checked' at a time, you will have to maintain that in the Checked event for the ListView control.

The full code for the sample is provided. The focus is on the mechanism described in the article regarding the Play/Pause action button. As a player, the sample app is minimalistic. It does not show time for the tracks or a progress bar. It is meant to simply demonstrate the implementation of an action button using the state image mechanism.

The tracks are posted in a separate zip file. To make the sample work, you have to download the 'tracks.zip' into a 'Tracks' subfolder under the project.