sources/Google.Solutions.Mvvm/Controls/BindableListView.cs (250 lines of code) (raw):
//
// Copyright 2020 Google LLC
//
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//
using Google.Solutions.Common.Linq;
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Diagnostics;
using System.Drawing;
using System.Linq;
using System.Windows.Forms;
namespace Google.Solutions.Mvvm.Controls
{
/// <summary>
/// Listview that support simple data binding.
/// </summary>
public class BindableListView<TModelItem> : ListView
{
private ICollection<TModelItem>? model;
private readonly Dictionary<int, Func<TModelItem, string>> columnAccessors =
new Dictionary<int, Func<TModelItem, string>>();
private Func<TModelItem, int>? imageIndexAccessor = null;
[Browsable(true)]
public bool AutoResizeColumnsOnUpdate { get; set; } = false;
private string? ExtractColumnValue(int columnIndex, TModelItem modelItem)
{
if (this.columnAccessors.TryGetValue(columnIndex, out var accessorFunc))
{
return accessorFunc(modelItem);
}
else
{
return null;
}
}
private int ExtractImageIndex(TModelItem modelItem)
{
return this.imageIndexAccessor == null
? 0 :
this.imageIndexAccessor(modelItem);
}
private ListViewItem FindViewItem(TModelItem modelItem)
{
return this.Items
.OfType<ListViewItem>()
.FirstOrDefault(item => Equals(item.Tag, modelItem));
}
private void AddViewItems(IEnumerable<TModelItem> items)
{
foreach (var item in items)
{
ObserveItem(item);
}
this.Items.AddRange(items
.Select(item => new ListViewItem(
this.Columns
.OfType<ColumnHeader>()
.Select(c => ExtractColumnValue(c.Index, item))
.ToArray())
{
Tag = item,
ImageIndex = ExtractImageIndex(item)
}).ToArray());
}
//---------------------------------------------------------------------
// Ctor.
//---------------------------------------------------------------------
public BindableListView()
{
//
// By default, double buffering is off. Especially in dark mode,
// this causes visible flickering.
//
this.DoubleBuffered = true;
}
//---------------------------------------------------------------------
// Overrides.
//---------------------------------------------------------------------
protected override void ScaleControl(SizeF factor, BoundsSpecified specified)
{
base.ScaleControl(factor, specified);
//
// Rescale existing columns. Columns that are added
// later need to be rescaled explicitly.
//
foreach (ColumnHeader column in this.Columns)
{
column.Width = LogicalToDeviceUnits(column.Width);
}
}
//---------------------------------------------------------------------
// Selection properties.
//---------------------------------------------------------------------
public event EventHandler? SelectedModelItemsChanged;
public event EventHandler? SelectedModelItemChanged;
protected override void OnSelectedIndexChanged(EventArgs e)
{
this.SelectedModelItemsChanged?.Invoke(this, e);
this.SelectedModelItemChanged?.Invoke(this, e);
}
public IEnumerable<TModelItem> SelectedModelItems
{
get => this.SelectedItems
.OfType<ListViewItem>()
.Select(item => (TModelItem)item.Tag);
set
{
this.SelectedIndices.Clear();
if (value != null)
{
foreach (var selectedItem in value)
{
var index = this.Items.IndexOf(FindViewItem(selectedItem));
Debug.Assert(index >= 0);
this.SelectedIndices.Add(index);
}
}
this.SelectedModelItemsChanged?.Invoke(this, EventArgs.Empty);
}
}
public TModelItem SelectedModelItem
{
get => this.SelectedItems
.OfType<ListViewItem>()
.Select(item => (TModelItem)item.Tag)
.FirstOrDefault();
set
{
this.SelectedIndices.Clear();
if (value != null)
{
var index = this.Items.IndexOf(FindViewItem(value));
Debug.Assert(index >= 0);
this.SelectedIndices.Add(index);
}
this.SelectedModelItemChanged?.Invoke(this, EventArgs.Empty);
}
}
//---------------------------------------------------------------------
// List Binding.
//---------------------------------------------------------------------
public void BindCollection(ICollection<TModelItem> model)
{
// Reset.
if (this.model != null)
{
UnobserveItems(this.model);
if (this.model is INotifyCollectionChanged observable)
{
observable.CollectionChanged -= Model_CollectionChanged;
}
}
this.Items.Clear();
// Configure control.
this.model = model;
if (this.model != null)
{
AddViewItems(this.model);
if (this.model is INotifyCollectionChanged observable)
{
observable.CollectionChanged += Model_CollectionChanged;
}
}
}
public void BindColumn(int columnIndex, Func<TModelItem, string> accessorFunc)
{
this.columnAccessors[columnIndex] = accessorFunc;
}
public void BindImageIndex(Func<TModelItem, int> accessorFunc)
{
this.imageIndexAccessor = accessorFunc;
}
//---------------------------------------------------------------------
// Change event handlers.
//---------------------------------------------------------------------
private void ObserveItem(TModelItem item)
{
if (item is INotifyPropertyChanged observableItem)
{
observableItem.PropertyChanged += ModelItem_PropertyChanged;
}
}
private void UnobserveItems(IEnumerable<TModelItem> items)
{
foreach (var item in items)
{
UnobserveItem(item);
}
}
private void UnobserveItem(TModelItem item)
{
if (item is INotifyPropertyChanged observableItem)
{
observableItem.PropertyChanged -= ModelItem_PropertyChanged;
}
}
private void Model_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
switch (e.Action)
{
case NotifyCollectionChangedAction.Add:
AddViewItems(e.NewItems.OfType<TModelItem>());
break;
case NotifyCollectionChangedAction.Remove:
foreach (var oldModelItem in e.OldItems.OfType<TModelItem>())
{
var oldViewItem = FindViewItem(oldModelItem);
if (oldViewItem != null)
{
UnobserveItem(oldModelItem);
this.Items.Remove(oldViewItem);
}
}
break;
case NotifyCollectionChangedAction.Replace:
if (e.OldItems.Count == e.NewItems.Count)
{
var count = e.OldItems.Count;
for (var i = 0; i < count; i++)
{
var oldModelItem = (TModelItem)e.OldItems[i];
var newModelItem = (TModelItem)e.NewItems[i];
var viewItem = FindViewItem(oldModelItem);
if (viewItem != null)
{
UnobserveItem(oldModelItem);
ObserveItem(newModelItem);
viewItem.Tag = newModelItem;
foreach (ColumnHeader column in this.Columns)
{
viewItem.SubItems[column.Index].Text =
ExtractColumnValue(column.Index, newModelItem);
}
}
}
}
break;
case NotifyCollectionChangedAction.Move:
foreach (var oldItem in e.OldItems.OfType<TModelItem>())
{
var viewItem = FindViewItem(oldItem);
if (oldItem != null)
{
this.Items.Remove(viewItem);
this.Items.Insert(e.NewStartingIndex, viewItem);
}
}
break;
case NotifyCollectionChangedAction.Reset:
// Reload everything.
UnobserveItems(this.Items.Cast<ListViewItem>()
.Select(i => i.Tag)
.OfType<TModelItem>());
this.Items.Clear();
AddViewItems(this.model.EnsureNotNull());
break;
default:
break;
}
if (this.AutoResizeColumnsOnUpdate)
{
AutoResizeColumns(ColumnHeaderAutoResizeStyle.ColumnContent);
AutoResizeColumns(ColumnHeaderAutoResizeStyle.HeaderSize);
}
}
private void ModelItem_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
// Rehydrate the entire list item.
var modelItem = (TModelItem)sender;
var viewItem = FindViewItem(modelItem);
if (viewItem != null)
{
viewItem.ImageIndex = ExtractImageIndex(modelItem);
foreach (var column in this.Columns.OfType<ColumnHeader>())
{
viewItem.SubItems[column.Index].Text = ExtractColumnValue(column.Index, modelItem);
}
}
}
}
}