diff --git a/src/Perspex.Base/Collections/PerspexDictionary.cs b/src/Perspex.Base/Collections/PerspexDictionary.cs
new file mode 100644
index 0000000000..3c981d0ac2
--- /dev/null
+++ b/src/Perspex.Base/Collections/PerspexDictionary.cs
@@ -0,0 +1,237 @@
+// -----------------------------------------------------------------------
+//
+// Copyright 2015 MIT Licence. See licence.md for more information.
+//
+// -----------------------------------------------------------------------
+
+namespace Perspex.Collections
+{
+ using System;
+ using System.Collections;
+ using System.Collections.Generic;
+ using System.Collections.Specialized;
+ using System.ComponentModel;
+ using System.Linq;
+
+ ///
+ /// A notifying dictionary.
+ ///
+ /// The type of the dictionary key.
+ /// The type of the dictionary value.
+ public class PerspexDictionary : IDictionary,
+ INotifyCollectionChanged,
+ INotifyPropertyChanged
+ {
+ private Dictionary inner;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public PerspexDictionary()
+ {
+ this.inner = new Dictionary();
+ }
+
+ ///
+ /// Occurs when the collection changes.
+ ///
+ public event NotifyCollectionChangedEventHandler CollectionChanged;
+
+ ///
+ /// Raised when a property on the collection changes.
+ ///
+ public event PropertyChangedEventHandler PropertyChanged;
+
+ ///
+ public int Count
+ {
+ get { return this.inner.Count; }
+ }
+
+ ///
+ public bool IsReadOnly
+ {
+ get { return false; }
+ }
+
+ ///
+ public ICollection Keys
+ {
+ get { return this.inner.Keys; }
+ }
+
+ ///
+ public ICollection Values
+ {
+ get { return this.inner.Values; }
+ }
+
+ ///
+ /// Gets or sets the named resource.
+ ///
+ /// The resource key.
+ /// The resource, or null if not found.
+ public TValue this[TKey key]
+ {
+ get
+ {
+ return this.inner[key];
+ }
+
+ set
+ {
+ TValue old;
+ bool replace = this.inner.TryGetValue(key, out old);
+ this.inner[key] = value;
+
+ if (replace)
+ {
+ if (this.PropertyChanged != null)
+ {
+ this.PropertyChanged(this, new PropertyChangedEventArgs($"Item[{key}]"));
+ }
+
+ if (this.CollectionChanged != null)
+ {
+ var e = new NotifyCollectionChangedEventArgs(
+ NotifyCollectionChangedAction.Replace,
+ new KeyValuePair(key, value),
+ new KeyValuePair(key, old));
+ this.CollectionChanged(this, e);
+ }
+ }
+ else
+ {
+ this.NotifyAdd(key, value);
+ }
+ }
+ }
+
+ ///
+ public void Add(TKey key, TValue value)
+ {
+ this.inner.Add(key, value);
+ this.NotifyAdd(key, value);
+ }
+
+ ///
+ public void Clear()
+ {
+ var old = this.inner;
+
+ this.inner = new Dictionary();
+
+ if (this.PropertyChanged != null)
+ {
+ this.PropertyChanged(this, new PropertyChangedEventArgs("Count"));
+ this.PropertyChanged(this, new PropertyChangedEventArgs($"Item[]"));
+ }
+
+ if (this.CollectionChanged != null)
+ {
+ var e = new NotifyCollectionChangedEventArgs(
+ NotifyCollectionChangedAction.Remove,
+ old.ToList(),
+ -1);
+ this.CollectionChanged(this, e);
+ }
+ }
+
+ ///
+ public bool ContainsKey(TKey key)
+ {
+ return this.inner.ContainsKey(key);
+ }
+
+ ///
+ public void CopyTo(KeyValuePair[] array, int arrayIndex)
+ {
+ ((IDictionary)this.inner).CopyTo(array, arrayIndex);
+ }
+
+ ///
+ public IEnumerator> GetEnumerator()
+ {
+ return this.inner.GetEnumerator();
+ }
+
+ ///
+ public bool Remove(TKey key)
+ {
+ TValue value;
+
+ if (this.inner.TryGetValue(key, out value))
+ {
+ if (this.PropertyChanged != null)
+ {
+ this.PropertyChanged(this, new PropertyChangedEventArgs("Count"));
+ this.PropertyChanged(this, new PropertyChangedEventArgs($"Item[{key}]"));
+ }
+
+ if (this.CollectionChanged != null)
+ {
+ var e = new NotifyCollectionChangedEventArgs(
+ NotifyCollectionChangedAction.Remove,
+ new[] { new KeyValuePair(key, value) },
+ -1);
+ this.CollectionChanged(this, e);
+ }
+
+ return true;
+ }
+ else
+ {
+ return false;
+ }
+ }
+
+ ///
+ public bool TryGetValue(TKey key, out TValue value)
+ {
+ return this.inner.TryGetValue(key, out value);
+ }
+
+ ///
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ return this.inner.GetEnumerator();
+ }
+
+ ///
+ void ICollection>.Add(KeyValuePair item)
+ {
+ this.Add(item.Key, item.Value);
+ }
+
+ ///
+ bool ICollection>.Contains(KeyValuePair item)
+ {
+ return this.inner.Contains(item);
+ }
+
+ ///
+ bool ICollection>.Remove(KeyValuePair item)
+ {
+ return this.Remove(item.Key);
+ }
+
+ private void NotifyAdd(TKey key, TValue value)
+ {
+ if (this.PropertyChanged != null)
+ {
+ this.PropertyChanged(this, new PropertyChangedEventArgs("Count"));
+ this.PropertyChanged(this, new PropertyChangedEventArgs($"Item[{key}]"));
+ }
+
+ if (this.CollectionChanged != null)
+ {
+ var val = new KeyValuePair(key, value);
+ var e = new NotifyCollectionChangedEventArgs(
+ NotifyCollectionChangedAction.Add,
+ new[] { new KeyValuePair(key, value) },
+ -1);
+ this.CollectionChanged(this, e);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Perspex.Base/Perspex.Base.csproj b/src/Perspex.Base/Perspex.Base.csproj
index b26cfaf677..654fbb3926 100644
--- a/src/Perspex.Base/Perspex.Base.csproj
+++ b/src/Perspex.Base/Perspex.Base.csproj
@@ -42,6 +42,7 @@
Properties\SharedAssemblyInfo.cs
+
diff --git a/tests/Perspex.Base.UnitTests/Collections/CollectionChangedTracker.cs b/tests/Perspex.Base.UnitTests/Collections/CollectionChangedTracker.cs
new file mode 100644
index 0000000000..27ec09f3cf
--- /dev/null
+++ b/tests/Perspex.Base.UnitTests/Collections/CollectionChangedTracker.cs
@@ -0,0 +1,36 @@
+// -----------------------------------------------------------------------
+//
+// Copyright 2015 MIT Licence. See licence.md for more information.
+//
+// -----------------------------------------------------------------------
+
+namespace Perspex.Base.UnitTests.Collections
+{
+ using System;
+ using System.Collections.Specialized;
+
+ internal class CollectionChangedTracker
+ {
+ public CollectionChangedTracker(INotifyCollectionChanged collection)
+ {
+ collection.CollectionChanged += this.CollectionChanged;
+ }
+
+ public NotifyCollectionChangedEventArgs Args { get; private set; }
+
+ public void Reset()
+ {
+ this.Args = null;
+ }
+
+ private void CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
+ {
+ if (this.Args != null)
+ {
+ throw new Exception("CollectionChanged called more than once.");
+ }
+
+ this.Args = e;
+ }
+ }
+}
diff --git a/tests/Perspex.Base.UnitTests/Collections/PerspexDictionaryTests.cs b/tests/Perspex.Base.UnitTests/Collections/PerspexDictionaryTests.cs
new file mode 100644
index 0000000000..4777811a53
--- /dev/null
+++ b/tests/Perspex.Base.UnitTests/Collections/PerspexDictionaryTests.cs
@@ -0,0 +1,154 @@
+// -----------------------------------------------------------------------
+//
+// Copyright 2015 MIT Licence. See licence.md for more information.
+//
+// -----------------------------------------------------------------------
+
+namespace Perspex.Base.UnitTests.Collections
+{
+ using System.Collections.Generic;
+ using System.Collections.Specialized;
+ using Perspex.Collections;
+ using Xunit;
+
+ public class PerspexDictionaryTests
+ {
+ [Fact]
+ public void Adding_Item_Should_Raise_CollectionChanged()
+ {
+ var target = new PerspexDictionary();
+ var tracker = new CollectionChangedTracker(target);
+
+ target.Add("foo", "bar");
+
+ Assert.NotNull(tracker.Args);
+ Assert.Equal(NotifyCollectionChangedAction.Add, tracker.Args.Action);
+ Assert.Equal(-1, tracker.Args.NewStartingIndex);
+ Assert.Equal(1, tracker.Args.NewItems.Count);
+ Assert.Equal(new KeyValuePair("foo", "bar"), tracker.Args.NewItems[0]);
+ }
+
+ [Fact]
+ public void Adding_Item_Should_Raise_PropertyChanged()
+ {
+ var target = new PerspexDictionary();
+ var tracker = new PropertyChangedTracker(target);
+
+ target.Add("foo", "bar");
+
+ Assert.Equal(new[] { "Count", "Item[foo]" }, tracker.Names);
+ }
+
+ [Fact]
+ public void Assigning_Item_Should_Raise_CollectionChanged_Add()
+ {
+ var target = new PerspexDictionary();
+ var tracker = new CollectionChangedTracker(target);
+
+ target["foo"] = "bar";
+
+ Assert.NotNull(tracker.Args);
+ Assert.Equal(NotifyCollectionChangedAction.Add, tracker.Args.Action);
+ Assert.Equal(-1, tracker.Args.NewStartingIndex);
+ Assert.Equal(1, tracker.Args.NewItems.Count);
+ Assert.Equal(new KeyValuePair("foo", "bar"), tracker.Args.NewItems[0]);
+ }
+
+ [Fact]
+ public void Assigning_Item_Should_Raise_CollectionChanged_Replace()
+ {
+ var target = new PerspexDictionary();
+
+ target["foo"] = "baz";
+ var tracker = new CollectionChangedTracker(target);
+ target["foo"] = "bar";
+
+ Assert.NotNull(tracker.Args);
+ Assert.Equal(NotifyCollectionChangedAction.Replace, tracker.Args.Action);
+ Assert.Equal(-1, tracker.Args.NewStartingIndex);
+ Assert.Equal(1, tracker.Args.NewItems.Count);
+ Assert.Equal(new KeyValuePair("foo", "bar"), tracker.Args.NewItems[0]);
+ }
+
+ [Fact]
+ public void Assigning_Item_Should_Raise_PropertyChanged_Add()
+ {
+ var target = new PerspexDictionary();
+ var tracker = new PropertyChangedTracker(target);
+
+ target["foo"] = "bar";
+
+ Assert.Equal(new[] { "Count", "Item[foo]" }, tracker.Names);
+ }
+
+ [Fact]
+ public void Assigning_Item_Should_Raise_PropertyChanged_Replace()
+ {
+ var target = new PerspexDictionary();
+
+ target["foo"] = "baz";
+ var tracker = new PropertyChangedTracker(target);
+ target["foo"] = "bar";
+
+ Assert.Equal(new[] { "Item[foo]" }, tracker.Names);
+ }
+
+ [Fact]
+ public void Removing_Item_Should_Raise_CollectionChanged()
+ {
+ var target = new PerspexDictionary();
+
+ target["foo"] = "bar";
+ var tracker = new CollectionChangedTracker(target);
+ target.Remove("foo");
+
+ Assert.NotNull(tracker.Args);
+ Assert.Equal(NotifyCollectionChangedAction.Remove, tracker.Args.Action);
+ Assert.Equal(-1, tracker.Args.OldStartingIndex);
+ Assert.Equal(1, tracker.Args.OldItems.Count);
+ Assert.Equal(new KeyValuePair("foo", "bar"), tracker.Args.OldItems[0]);
+ }
+
+ [Fact]
+ public void Removing_Item_Should_Raise_PropertyChanged()
+ {
+ var target = new PerspexDictionary();
+
+ target["foo"] = "bar";
+ var tracker = new PropertyChangedTracker(target);
+ target.Remove("foo");
+
+ Assert.Equal(new[] { "Count", "Item[foo]" }, tracker.Names);
+ }
+
+ [Fact]
+ public void Clearing_Collection_Should_Raise_CollectionChanged()
+ {
+ var target = new PerspexDictionary();
+
+ target["foo"] = "bar";
+ target["baz"] = "qux";
+ var tracker = new CollectionChangedTracker(target);
+ target.Clear();
+
+ Assert.NotNull(tracker.Args);
+ Assert.Equal(NotifyCollectionChangedAction.Remove, tracker.Args.Action);
+ Assert.Equal(-1, tracker.Args.OldStartingIndex);
+ Assert.Equal(2, tracker.Args.OldItems.Count);
+ Assert.Equal(new KeyValuePair("foo", "bar"), tracker.Args.OldItems[0]);
+ }
+
+ [Fact]
+ public void Clearing_Collection_Should_Raise_PropertyChanged()
+ {
+ var target = new PerspexDictionary();
+
+ target["foo"] = "bar";
+ target["baz"] = "qux";
+ var tracker = new PropertyChangedTracker(target);
+ target.Clear();
+
+ Assert.Equal(new[] { "Count", "Item[]" }, tracker.Names);
+ }
+ }
+}
diff --git a/tests/Perspex.Base.UnitTests/Collections/PerspexListTests.cs b/tests/Perspex.Base.UnitTests/Collections/PerspexListTests.cs
index 4518fda7db..21ccdbc106 100644
--- a/tests/Perspex.Base.UnitTests/Collections/PerspexListTests.cs
+++ b/tests/Perspex.Base.UnitTests/Collections/PerspexListTests.cs
@@ -4,7 +4,7 @@
//
// -----------------------------------------------------------------------
-namespace Perspex.Base.UnitTests
+namespace Perspex.Base.UnitTests.Collections
{
using System;
using System.Collections.Generic;
diff --git a/tests/Perspex.Base.UnitTests/Collections/PropertyChangedTracker.cs b/tests/Perspex.Base.UnitTests/Collections/PropertyChangedTracker.cs
new file mode 100644
index 0000000000..8c7260f57a
--- /dev/null
+++ b/tests/Perspex.Base.UnitTests/Collections/PropertyChangedTracker.cs
@@ -0,0 +1,33 @@
+// -----------------------------------------------------------------------
+//
+// Copyright 2015 MIT Licence. See licence.md for more information.
+//
+// -----------------------------------------------------------------------
+
+namespace Perspex.Base.UnitTests.Collections
+{
+ using System;
+ using System.Collections.Generic;
+ using System.ComponentModel;
+
+ internal class PropertyChangedTracker
+ {
+ public PropertyChangedTracker(INotifyPropertyChanged obj)
+ {
+ this.Names = new List();
+ obj.PropertyChanged += this.PropertyChanged;
+ }
+
+ public List Names { get; private set; }
+
+ public void Reset()
+ {
+ this.Names.Clear();
+ }
+
+ private void PropertyChanged(object sender, PropertyChangedEventArgs e)
+ {
+ this.Names.Add(e.PropertyName);
+ }
+ }
+}
diff --git a/tests/Perspex.Base.UnitTests/Perspex.Base.UnitTests.csproj b/tests/Perspex.Base.UnitTests/Perspex.Base.UnitTests.csproj
index e7f901e257..de667b5823 100644
--- a/tests/Perspex.Base.UnitTests/Perspex.Base.UnitTests.csproj
+++ b/tests/Perspex.Base.UnitTests/Perspex.Base.UnitTests.csproj
@@ -76,6 +76,8 @@
+
+