IanG on Tap

Ian Griffiths in Weblog Form (RSS 2.0)

Blog Navigation

April (2018)

(1 item)

August (2014)

(1 item)

July (2014)

(5 items)

April (2014)

(1 item)

March (2014)

(1 item)

January (2014)

(2 items)

November (2013)

(2 items)

July (2013)

(4 items)

April (2013)

(1 item)

February (2013)

(6 items)

September (2011)

(2 items)

November (2010)

(4 items)

September (2010)

(1 item)

August (2010)

(4 items)

July (2010)

(2 items)

September (2009)

(1 item)

June (2009)

(1 item)

April (2009)

(1 item)

November (2008)

(1 item)

October (2008)

(1 item)

September (2008)

(1 item)

July (2008)

(1 item)

June (2008)

(1 item)

May (2008)

(2 items)

April (2008)

(2 items)

March (2008)

(5 items)

January (2008)

(3 items)

December (2007)

(1 item)

November (2007)

(1 item)

October (2007)

(1 item)

September (2007)

(3 items)

August (2007)

(1 item)

July (2007)

(1 item)

June (2007)

(2 items)

May (2007)

(8 items)

April (2007)

(2 items)

March (2007)

(7 items)

February (2007)

(2 items)

January (2007)

(2 items)

November (2006)

(1 item)

October (2006)

(2 items)

September (2006)

(1 item)

June (2006)

(2 items)

May (2006)

(4 items)

April (2006)

(1 item)

March (2006)

(5 items)

January (2006)

(1 item)

December (2005)

(3 items)

November (2005)

(2 items)

October (2005)

(2 items)

September (2005)

(8 items)

August (2005)

(7 items)

June (2005)

(3 items)

May (2005)

(7 items)

April (2005)

(6 items)

March (2005)

(1 item)

February (2005)

(2 items)

January (2005)

(5 items)

December (2004)

(5 items)

November (2004)

(7 items)

October (2004)

(3 items)

September (2004)

(7 items)

August (2004)

(16 items)

July (2004)

(10 items)

June (2004)

(27 items)

May (2004)

(15 items)

April (2004)

(15 items)

March (2004)

(13 items)

February (2004)

(16 items)

January (2004)

(15 items)

Blog Home

RSS 2.0

Writing

Programming C# 5.0

Programming WPF

Other Sites

Interact Software

Vertical Jiggle in Grouped ListViews

Friday 25 July, 2014, 02:42 PM

This is the fourth article in my series exploring some of the technical challenges I encountered while writing my Agenda View Windows Phone app. I initially promised four entries on the topic of showing grouped data in the ListView control, but I’ll be adding one more after this. (There are more to come on other aspects of the app though—I’ve got another six planned.)

Most of the articles in this series are applicable to both Windows and Windows Phone store apps, and the examples available for download in this entry are Universal apps. However, in this article I show how to work around a problem that only occurs on Windows Phone. The code works perfectly well in Universal apps with the workaround in place, but is not actually necessary if you’re only targeting Windows.

As my first blog entry in this series described, my app needs two levels of grouping: appointments are grouped by time, and then by day. That was tricky because the ListView supports just one level of grouping, but in the second and third articles, I showed what appears to be a satisfactory way to handle this: provide the ListView with a single level of grouping (by day) and use different item types to represent time headers and appointments within that. (Likewise, in the address-based example I’ve been using, we provide data grouped by country, and each group contains a mixture of elements representing town headers and addresses.)

However, there are two unresolved problems. First, there’s some occasional horizontal jiggling. That’s not a grouping issue—you can get the same issue with a flat list. As a quick fix for now, I’ll just set fixed widths on all the templates. This makes the problem go away but it’s not an ideal solution; I’ll show how to fix it properly in a later article. The second problem is that the approach I’ve shown so far only works if all your items are the same height. And since the nested group headers are just items as far as the ListView is concerned, this means your nested group headers must be the same height as your items.

To show what happens if the heights differ, I can add the following attribute to the TownGroup header from the final example from last time:

Margin="0,20,0,0"

Here’s a copy of the solution with this change for download. The following two screenshots show the original version, and then the version with the additional vertical margin for the town groups:

This makes it easier to see the group structure at a glance. My real app needs this sort of spacing to reproduce the look of the old app on which it is based.

Unfortunately, if you run this app on a phone, you’ll see a problem if you scroll down a few pages through the list, and then back up again. Although it looks fine when moving forward through the data, as you come back up again the sticky group title at the top of the page jiggles around vertically. (The Windows app doesn’t have this problem, because it doesn’t seem to support the sticky group headers. These only seem to be available for Windows Phone apps.)

This is a different issue from the horizontal jiggling I’ve mentioned before. That looks different, and it doesn’t occur in this particular example because I’ve applied a fixed width to every data template. (It’s superficially similar—both horizontal and vertical jiggle can be eliminated with fixed-size data templates. But the vertical jiggle seems only to occur with grouped data.)

As far as I can tell, this is a limitation of the phone ListView—you cannot use mixed-height items in conjunction with grouping, because you’ll end up with a vertically jiggling sticky group header. And as far as I can tell, you can’t even disable the sticky group header to avoid the problem. If you want mixed item heights in a ListView, you cannot use it in grouped mode. So that’s how I eventually came to the solution I used in my app.

Completely Flattened Groups

Because I want to have mixed item heights, I cannot use even the single-level group support offered by the ListView. So I need to take the same trick I already applied once—flattening the items in the nested groups—and apply it to the top-level groups too. So the structure ends up looking like this:

The only difference the end user will see is that the day group headers are no longer sticky—they will scroll out of view immediately, instead of hanging around for as long as at least part of the corresponding group is visible. Everything else looks exactly like it did before. But as far as the ListView is concerned, this is now just one flat list with no grouping.

I’m using the same item template selector as last time, but with more templates: I’ve now defined the day group header in there instead of in a group style, and you’ll also notice that my diagram features one extra item type. I’ve got a ‘Gap’ item between each day group. (The type is actually called EndOfDayGroup in my code, but that would have cluttered my already rather busy diagram.) That’s there to provide a gap between each day group. I couldn’t add it as margin at the top of the day group because that would mean when you first start the app, there’d be a weird gap above the TODAY group. I need the gap after every day group, but not before the first one. And while I could make the day group template’s height vary dynamically according to whether it was the first or not, it was simpler to do this.

(So I was slightly off in an earlier blog when I said I ended up with three item types. You can see four here. And in fact there are two more. I have another item type to represent the “loading data” progress indicator that appears when you’ve down to the end of the data the app has fetched so far, but we’re waiting for more data to come from the appointment API. And there’s yet another one that goes at the end of the list if we actually get to the very end of your appointments. If you have indefinitely recurring appointments, you’ll never see that one.)

Now the obvious way to flatten this data is to use much the same technique as I showed in the earlier entry on flattening nested groups. However, by the time I discovered this jiggling problem, I had already implemented my data source with real outer groups and flattened inner groups. For reasons I’ll describe in a later blog, I have a lot of code to handle updating a single-tier grouped structure, and moving to a flatter one was going to be a lot of work. (And I’m hoping that this jiggling issue is a WinRT bug that will be fixed one day, at which point I’d like to go back to using groups, because the sticky headers are nice.)

So I used a different approach: I wrote my own observable collection that wraps a grouped source (a list of lists) and presents it as a flat list. This collection also automatically adds items to represent the gaps between day groups, and also appends a single item to the end. (That final appended item is either the “loading” one or the one that indicates that there are no more appointments.)

For your entertainment, here’s the entire wrapper:

// -------------------------------------------------------------------------------------
// <copyright file="FlatteningObservableCollection.cs" company="Interact Software Ltd.">
// © 2014 Interact Software Ltd.
// </copyright>
// -------------------------------------------------------------------------------------
 
namespace AgendaView.Collections
{
    using System;
    using System.Collections;
    using System.Collections.Generic;
    using System.Collections.ObjectModel;
    using System.Collections.Specialized;
    using System.Linq;
 
    using AgendaView.Extensions;
 
    /// <summary>
    /// Wraps an observable collection of observable collections, and presents the contents
    /// as a single, flat change-notifying collection that includes not just the nested items,
    /// but also the groups themselves, end-of-group placeholders, and a final end-of-collection
    /// placeholder.
    /// </summary>
    /// <typeparam name="T">The type of items in the nested containers.</typeparam>
    /// <typeparam name="TContainer">The container type - must be assignable to
    /// <see cref="ObservableCollection{T}"/>.</typeparam>
    /// <typeparam name="TGroupEndPlaceholder">The type of the placeholder to add to the
    /// end of each group.</typeparam>
    /// <remarks>
    /// <para>
    /// This type does not inherit from <c>ObservableCollection</c> itself. Instead, it implements
    /// <c>INotifyCollectionChanged</c>, and <c>IList</c>, which is sufficient for XAML to observe
    /// our change notifications.
    /// </para>
    /// <para>
    /// This type is useful because there are issues with binding to nested data in WinRT XAML. Not
    /// only does the support for nested data stop at 1 level deep, even that doesn't work properly
    /// with heterogeneous data (at least, it doesn't as of Windows Phone 8.1) - if the list items have
    /// varying sizes, something causes the sticky group header at the top of the <c>ListView</c>
    /// to jiggle up and down when scrolling back up through the list. The only way to present
    /// variable-height items in a satisfactory way is to present them to the <c>ListView</c> as
    /// a flat collection.
    /// </para>
    /// <para>
    /// This collection's item type is <c>object</c> because the flattened collection can contain
    /// items, groups, and placeholders, and there is no common base class for all these types of
    /// items. (In particular, mixing the groups with the items makes it effectively impossible to
    /// have a common base, because the groups need to derive from <c>ObservableCollection</c>.)
    /// </para>
    /// </remarks>
    public class FlatteningObservableCollection<T, TContainer, TGroupEndPlaceholder>
        IList<object>, INotifyCollectionChangedIList
        where TContainer : ObservableCollection<T>
        where TGroupEndPlaceholder : new()
    {
        private readonly ObservableCollection<TContainer> _source;
        
        /// <summary>
        /// Our copy of the list of items.
        /// </summary>
        /// <remarks>
        /// We need this to be able to raise Remove notifications correctly when one of the
        /// child groups raises a Reset. (Unfortunately, when you get a Reset, all the items
        /// in the source have already gone, so if you want to know what was missing, you
        /// need to have maintained a copy.)
        /// </remarks>
        private readonly List<List<T>> _copies = new List<List<T>>();
 
        /// <summary>
        /// Includes items representing groups as well as those representing group members.
        /// </summary>
        private readonly List<int> _cumulativeSizeByGroup = new List<int>();
 
        private readonly List<TGroupEndPlaceholder> _endPlaceholders = new List<TGroupEndPlaceholder>();
 
        private T _endPlaceholder;
 
        /// <summary>
        /// Initializes a <see cref="FlatteningObservableCollection{T,TContainer,TGroupEndPlaceholder}"/>.
        /// </summary>
        /// <param name="source">The source collection of collections.</param>
        public FlatteningObservableCollection(ObservableCollection<TContainer> source)
        {
            _source = source;
 
            _source.CollectionChanged += OnSourceCollectionChanged;
            AddGroups(_source, 0);
        }
 
        /// <inheritdoc/>
        public event NotifyCollectionChangedEventHandler CollectionChanged;
 
        /// <summary>
        /// Gets or sets the final object to add to the end of the collection.
        /// </summary>
        public T EndPlaceholder
        {
            get
            {
                return _endPlaceholder;
            }
 
            set
            {
                if (!Equals(_endPlaceholder, value))
                {
                    var old = _endPlaceholder;
                    _endPlaceholder = value;
                    OnCollectionChanged(new NotifyCollectionChangedEventArgs(
                        NotifyCollectionChangedAction.Replace, value, old, Count - 1));
                }
            }
        }
 
        /// <inheritdoc/>
        public bool IsFixedSize
        {
            get { return false; }
        }
 
        /// <inheritdoc/>
        public bool IsSynchronized
        {
            get { return false; }
        }
 
        /// <inheritdoc/>
        public object SyncRoot
        {
            get { throw new NotImplementedException(); }
        }
 
        /// <inheritdoc/>
        public int Count
        {
            get
            {
                int sourceAndGroupItems = _cumulativeSizeByGroup.Count == 0
                    ? 0
                    : _cumulativeSizeByGroup[_cumulativeSizeByGroup.Count - 1];
                return sourceAndGroupItems + 1;
            }
        }
 
        /// <inheritdoc/>
        public bool IsReadOnly
        {
            get { return true; }
        }
 
        /// <inheritdoc/>
        public object this[int index]
        {
            get
            {
                if (index == Count - 1)
                {
                    return EndPlaceholder;
                }
 
                if (index == 0)
                {
                    return _source[0];
                }
 
                int searchResult = _cumulativeSizeByGroup.BinarySearch(index);
                if (searchResult < 0)
                {
                    // Not an exact hit, so this is the index of an item within a
                    // group, not of a group itself.
                    // Search result will be 1's complement of group containing this.
                    int groupIndex = searchResult ^ -1;
 
                    int countAtStartOfGroup = groupIndex == 0 ?
                        0 :
                        _cumulativeSizeByGroup[groupIndex - 1];
                    int indexWithinGroup = index - countAtStartOfGroup - 1;
                    return indexWithinGroup == _source[groupIndex].Count
                        ? (object) _endPlaceholders[groupIndex]
                        : _source[groupIndex][indexWithinGroup];
                }
 
                return _source[searchResult + 1];
            }
 
            set
            {
                throw new NotImplementedException();
            }
        }
 
        /// <inheritdoc/>
        public IEnumerator<object> GetEnumerator()
        {
            int gi = 0;
            foreach (TContainer container in _source)
            {
                yield return container;
                foreach (var item in container)
                {
                    yield return item;
                }
 
                yield return _endPlaceholders[gi];
                gi += 1;
            }
 
            yield return EndPlaceholder;
        }
 
        /// <inheritdoc/>
        IEnumerator IEnumerable.GetEnumerator()
        {
            return GetEnumerator();
        }
 
        /// <inheritdoc/>
        public void Add(object item)
        {
            throw new NotImplementedException();
        }
 
        /// <inheritdoc/>
        int IList.Add(object value)
        {
            throw new NotImplementedException();
        }
 
        /// <inheritdoc/>
        public void Clear()
        {
            throw new NotImplementedException();
        }
 
        /// <inheritdoc/>
        void IList.Remove(object value)
        {
            throw new NotImplementedException();
        }
 
        /// <inheritdoc/>
        public void RemoveAt(int index)
        {
            throw new NotImplementedException();
        }
 
        /// <inheritdoc/>
        public bool Contains(object item)
        {
            throw new NotImplementedException();
        }
 
        /// <inheritdoc/>
        public void CopyTo(object[] array, int arrayIndex)
        {
            throw new NotImplementedException();
        }
 
        /// <inheritdoc/>
        public bool Remove(object item)
        {
            throw new NotImplementedException();
        }
 
        /// <inheritdoc/>
        public void CopyTo(Array array, int index)
        {
            throw new NotImplementedException();
        }
 
        /// <inheritdoc/>
        public int IndexOf(object item)
        {
            var thisEnum = this.AsEnumerable();
            var c = thisEnum.Count(x => !ReferenceEquals(x, item));
            return c == Count ? -1 : c;
        }
 
        /// <inheritdoc/>
        public void Insert(int index, object item)
        {
            throw new NotImplementedException();
        }
 
        private void AddGroups(IEnumerable<TContainer> groups, int insertionIndex)
        {
            int i = insertionIndex;
            int previousIndex = insertionIndex - 1;
            int totalToHere = previousIndex < 0 || previousIndex >= _cumulativeSizeByGroup.Count
                ? 0
                : _cumulativeSizeByGroup[previousIndex];
            int totalAdded = 0;
            foreach (TContainer container in groups)
            {
                int toAdd = container.Count + 2;
                totalToHere += toAdd;
                totalAdded += toAdd;
                _cumulativeSizeByGroup.Insert(i, totalToHere);
                _copies.Insert(i, container.ToCopyList());
                _endPlaceholders.Add(new TGroupEndPlaceholder());
                i += 1;
                container.CollectionChanged += OnContainerInSourceChanged;
            }
 
            while (i < _cumulativeSizeByGroup.Count)
            {
                _cumulativeSizeByGroup[i] += totalAdded;
                i += 1;
            }
        }
 
        private void RemoveGroups(IEnumerable<TContainer> groups, int removalIndex)
        {
            int totalRemoved = 0;
            int groupCount = 0;
            foreach (TContainer container in groups)
            {
                totalRemoved += container.Count + 2;
                groupCount += 1;
                container.CollectionChanged -= OnContainerInSourceChanged;
            }
 
            _cumulativeSizeByGroup.RemoveRange(removalIndex, groupCount);
            _copies.RemoveRange(removalIndex, groupCount);
            _endPlaceholders.RemoveRange(removalIndex, groupCount);
            for (int i = removalIndex; i < _cumulativeSizeByGroup.Count; ++i)
            {
                _cumulativeSizeByGroup[i] -= totalRemoved;
            }
        }
 
        private void ReplaceGroups(
            IEnumerable<TContainer> oldGroups, IEnumerable<TContainer> newGroups, int index)
        {
            int totalChange = 0;
            int i = index;
            foreach (var pair in oldGroups.Zip(
                newGroups, (oldContainer, newContainer) => new { oldContainer, newContainer }))
            {
                totalChange += pair.newContainer.Count - pair.oldContainer.Count;
                _cumulativeSizeByGroup[i] += totalChange;
                _copies[i] = pair.newContainer.ToCopyList();
                _endPlaceholders[i] = new TGroupEndPlaceholder();
                i += 1;
                pair.oldContainer.CollectionChanged -= OnContainerInSourceChanged;
                pair.newContainer.CollectionChanged += OnContainerInSourceChanged;
            }
 
            while (i < _cumulativeSizeByGroup.Count)
            {
                _cumulativeSizeByGroup[i] += totalChange;
                i += 1;
            }
        }
 
        private void OnContainerInSourceChanged(object sender, NotifyCollectionChangedEventArgs e)
        {
            var container = (TContainer) sender;
            int gi = _source.IndexOf(container);
            int baseIndex = gi == 0 ? 0 : _cumulativeSizeByGroup[gi - 1];
            switch (e.Action)
            {
                case NotifyCollectionChangedAction.Add:
                    _copies[gi].InsertRange(e.NewStartingIndex, e.NewItems.Cast<T>());
                    for (int i = gi; i < _cumulativeSizeByGroup.Count; ++i)
                    {
                        _cumulativeSizeByGroup[i] += e.NewItems.Count;
                    }
 
                    OnCollectionChanged(new NotifyCollectionChangedEventArgs(
                        NotifyCollectionChangedAction.Add,
                        e.NewItems,
                        baseIndex + e.NewStartingIndex + 1));
                    break;
 
                case NotifyCollectionChangedAction.Remove:
                    _copies[gi].RemoveRange(e.OldStartingIndex, e.OldItems.Count);
                    for (int i = gi; i < _cumulativeSizeByGroup.Count; ++i)
                    {
                        _cumulativeSizeByGroup[i] -= e.OldItems.Count;
                    }
 
                    OnCollectionChanged(new NotifyCollectionChangedEventArgs(
                        NotifyCollectionChangedAction.Remove,
                        e.OldItems,
                        baseIndex + e.OldStartingIndex + 1));
                    break;
 
                case NotifyCollectionChangedAction.Replace:
                    for (int i = 0; i < e.NewItems.Count; ++i)
                    {
                        _copies[gi][i + e.NewStartingIndex] = (T) e.NewItems[i];
                    }
 
                    OnCollectionChanged(new NotifyCollectionChangedEventArgs(
                        NotifyCollectionChangedAction.Replace,
                        e.NewItems,
                        e.OldItems,
                        baseIndex + e.NewStartingIndex + 1));
                    break;
 
                case NotifyCollectionChangedAction.Reset:
                    var itemsRemoved = _copies[gi];
                    _copies[gi] = new List<T>();
                    for (int i = gi; i < _cumulativeSizeByGroup.Count; ++i)
                    {
                        _cumulativeSizeByGroup[i] -= itemsRemoved.Count;
                    }
 
                    OnCollectionChanged(new NotifyCollectionChangedEventArgs(
                        NotifyCollectionChangedAction.Remove,
                        itemsRemoved,
                        baseIndex + 1));
                    break;
 
                case NotifyCollectionChangedAction.Move:
                    throw new NotImplementedException();
                default:
                    throw new ArgumentOutOfRangeException();
            }
        }
 
        private IList GroupsAndItemsFromGroups(IList groups, int startGroupIndex)
        {
            return groups
                .Cast<TContainer>()
                .SelectMany((g, i) => new object[] { g }
                 .Concat(g.Cast<object>())
                 .Concat(new object[] { _endPlaceholders[startGroupIndex + i] }))
                .ToList();
        }
 
        private void OnSourceCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
        {
            IList itemsRemoved;
            switch (e.Action)
            {
                case NotifyCollectionChangedAction.Add:
                    // Apparently ListView does not cope well with multi-item change notifications,
                    // so we need to do one at a time.
                    for (int i = 0; i < e.NewItems.Count; ++i)
                    {
                        var container = (TContainer) e.NewItems[i];
 
                        // Originally, we added all the groups at once, but we now have
                        // to do them one at a time to make sure the collection state is
                        // consistent with the changes advertised so far.
                        // In fact this is a bit smelly (as is the Remove code) because
                        // AddGroups causes multiple items to become visible externally
                        // at once, so the collection gets slightly ahead of the events.
                        // (But not by as much as if we added all the groups at once).
                        AddGroups(new[] { container }, e.NewStartingIndex + i);
                        int idx = (e.NewStartingIndex + i) == 0
                            ? 0 : _cumulativeSizeByGroup[e.NewStartingIndex + i - 1];
                        OnCollectionChanged(new NotifyCollectionChangedEventArgs(
                            NotifyCollectionChangedAction.Add,
                            new[] { container },
                            idx));
 
                        for (int j = 0; j < container.Count; ++j)
                        {
                            OnCollectionChanged(new NotifyCollectionChangedEventArgs(
                                NotifyCollectionChangedAction.Add,
                                new[] { container[j] },
                                idx + 1 + j));
                        }
 
                        OnCollectionChanged(new NotifyCollectionChangedEventArgs(
                            NotifyCollectionChangedAction.Add,
                            _endPlaceholders[e.NewStartingIndex],
                            idx + 1 + container.Count));
                    }
 
                    break;
 
                case NotifyCollectionChangedAction.Remove:
                    // Apparently ListView does not cope well with multi-item change notifications,
                    // so we need to do one at a time.
                    foreach (TContainer container in e.OldItems)
                    {
                        var containerInCollection = new[] { container };
 
                        itemsRemoved = GroupsAndItemsFromGroups(containerInCollection, e.OldStartingIndex);
                        RemoveGroups(containerInCollection, e.OldStartingIndex);
                        int idx = e.OldStartingIndex == 0 ? 0 : _cumulativeSizeByGroup[e.OldStartingIndex - 1];
                        foreach (object item in itemsRemoved)
                        {
                            OnCollectionChanged(new NotifyCollectionChangedEventArgs(
                                NotifyCollectionChangedAction.Remove,
                                item,
                                idx));
                        }
                    }
 
                    break;
 
                case NotifyCollectionChangedAction.Replace:
                    itemsRemoved = GroupsAndItemsFromGroups(e.OldItems, e.OldStartingIndex);
                    ReplaceGroups(
                        e.OldItems.Cast<TContainer>(),
                        e.NewItems.Cast<TContainer>(), e.OldStartingIndex);
                    OnCollectionChanged(new NotifyCollectionChangedEventArgs(
                        NotifyCollectionChangedAction.Replace,
                        GroupsAndItemsFromGroups(e.NewItems, e.NewStartingIndex),
                        itemsRemoved,
                        e.OldStartingIndex == 0 ? 0 : _cumulativeSizeByGroup[e.OldStartingIndex - 1]));
                    break;
 
                case NotifyCollectionChangedAction.Reset:
                    _cumulativeSizeByGroup.Clear();
                    _copies.Clear();
                    OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
                    break;
 
                case NotifyCollectionChangedAction.Move:
                    throw new NotImplementedException();
                default:
                    throw new ArgumentOutOfRangeException();
            }
        }
 
        private void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
        {
            if (CollectionChanged != null)
            {
                CollectionChanged(this, e);
            }
        }
    }
}

You initialize this with an ObservableCollection<TContainer>, where TContainer is top level group type. (In my address-based examples from earlier blogs, that would be CountryGroup.) This group type must inherit from ObservableCollection<T>. The flattened list example I showed in an earlier blog had T as object, because each country group contains a mixture of town header and address items. In my real app, T is a base class common to the view models for all of the things that end up in the flattened list.

Custom Collection Change Notification Implementation

Since my app needs to be able to update the list (e.g., when the user adds or removes an appointment), I need to support list change notifications—that’s why I require the incoming groups to derive from ObservableCollection<T>. But my wrapper does not. Instead, it implements INotifyCollectionChanged directly.

When you write a custom INotifyCollectionChanged implementation for a store app, it must also provide IList. (I also chose to implement IList<object> for my own convenience, but XAML data binding doesn’t care about that.) If you don’t supply a non-generic IList implementation, your INotifyCollectionChanged will be ignored.

An alternative way to implement this would be to maintain an independent ObservableCollection<T> that is essentially a flattened copy of the underlying collections, and to write code to keep it in sync. But I chose to make it a wrapper—if you look at the implementation for the indexer and other collection members, you’ll see it ultimately retrieves data from the underlying collections. My reason was twofold: first, some phones have very limited memory, and unnecessary duplication is wasteful (although in practice, it’s just references we’re duplicating, and there aren’t that many of them); second, the support for batched change notifications in INotifyCollectionChanged enables a flattening wrapper to report changes in the underlying data more efficiently than would be possible when maintaining a flattened copy.

However, I may change that in the future, because there’s a frustrating problem.

Store Apps Don’t Like Batch Change Notifications

It turns out that there is a limitation in store app data binding: it doesn’t seem to support data sources that provide batch change notifications. This means my wrapper cannot ensure that the collection state visible through the indexer is always consistent with the state implied by change notification events. The problem is that certain changes in the source data require me to report multiple changes. For example, if an entire group is removed from the source, I need to report the removal of every item in that group (because my flattening wrapper presents each group as a flattened version of its contents).

In theory, the most efficient way to do this is to provide a batch change notification—the INotifyCollectionChanged interface is designed to accommodate this very scenario. Unfortunately, batch change events seem to confuse the ListView hopelessly. In practice, it only works if you raise changes one item at a time. So when a group vanishes, I have to generate multiple change notifications.

This creates a problem. By the time the source notifies me of a group’s removal, the underlying data has already gone. And since my wrapper effectively presents just a view over the underlying data, this means that by the time I raise the first removal notification, the entire group’s contents are no longer available. That’s not really supposed to happen—it means that if any code happens to look inside the collection before I’ve finished raising change notifications, it’ll be confused. (E.g., suppose the first group vanishes, and that it had been flattened to 10 items. Directly after that happens, the item previously visible at offset 11 is now at offset 1, but at the point at which I’ve raised only a single notification, you’d expect it to be at offset 10.) With batch notifications it’s not a problem because you can raise a single event saying “these 10 items just vanished”. But if you can’t do that, you end up raising an event that says “this one item just vanished” even though another 9 have also already vanished.

As it happens, in my app items usually come and go one at a time anyway, and in practice, groups are empty by the time they are removed, so this isn’t a problem. However, it makes me uncomfortable—this is a non-obvious invariant the rest of the system has to maintain, so it could easily be a cause of bugs in future. So at some point I might avoid this problem by maintaining a separate ObservableCollection<T>, and updating that as the source data changes. When a group goes from the source, I’d have to remove the items from the copy one at a time, causing the multiple change notifications that the ListView seems to need, but this time, the collection it sees would remain consistent with the events, because I’d be updating the collection itself one element at a time.

Conclusion

With this collection flattener, I was able to take my single-level partially-flattened group model and present it to the ListView as a completely flat model. (With hindsight, I could perhaps have used two of these flattening wrappers, meaning my underlying data source could have used a fully nested group structure that directly corresponds to the logical structure the user sees. But as is so often the case with software, the route you would take with the benefit of hindsight may be quite different to the route you take when you’re learning about the obstacles as you go.) Used in conjunction with the template selector from last time, this enables me to create the look that I need, and by letting the ListView think that this is not a grouped data source, I avoid the vertical jiggling problems that occur with mixed item heights in a grouped list.

Copyright © 2002-2024, Interact Software Ltd. Content by Ian Griffiths. Please direct all Web site inquiries to webmaster@interact-sw.co.uk