It's been a while since my last Xamarinpost, but since I just released a Stylophone update, I'm in the mood to wax .NET again!
Stylophone, as a Windows-first app, was built upon the principle of MVVM, which as a quick refresher:
- Isolates model and view code entirely
- Uses "ViewModel" classes as glue between the two, exposing the model's data as properties the view picks up through data binding.
One of the key advantages of this pattern is, as the MS documentation says:
The app UI can be redesigned without touching the view model and model code [...]. Therefore, a new version of the view should work with the existing view model.
So if you can easily have multiple sets of UI with the same base... You should be able to have the same code for multiple platforms that run .NET, and only remake the view for each platform! That's what Stylophone does.
Data-binding comes for free on Windows thanks to XAML, but what about iOS?
Can we bind our .NET code to native UIKit views so they just update automagically?
As it turns out, yes! There is a native data-binding mechanism for Apple platforms!
Well, kinda.
Cocoa Bindings
Cocoa Bindings is a piece of AppKit tech that allows you to do data-binding on Mac apps out of the box. It just works!™️
Apple's flavor of data-binding relies on two elements:
- Key-Value Coding (KVC), which gives access to an object’s property with a specified name (a Keypath)
- Key-Value Observing (KVO), which allows an object to receive notifications of changes to values in other objects
For dotnetheads, this is basically C# properties and INotifyPropertyChanged
.
That all sounds nice and easy to plug into..except Cocoa Bindings aren't available on UIKit/iOS.
The components are, however!
Reimplementing Cocoa Bindings
Since UIKit still supports key-value coding, we can just create our own Bindings.
Creating a .NET databinder for iOS basically means doing two things:
- Keep track of
PropertyChanged
events on the C#/ViewModel side and update your views through KVC - Keep track of view changes through KVO and update your properties on the ViewModel
First, let's create our own Binding
class that would act as universal glue code between C# and native objects.
Xamarin's automatic type conversion is very useful here, so we don't need to write a lot of code at all:
public class Binding
{
// Native UIKit object, and path to its KVC property
public NSObject Object { get; set; }
public NSString Keypath { get; set; }
// C# property, this uses reflection
public PropertyInfo Property { get; set; }
/// Update our C# property with information from Key-Value Observing
/// Since KVO cannot give you type information, you must provide the type yourself.
public void UpdateProperty<T>(INotifyPropertyChanged targeTViewModel, NSObservedChange change)
{
var nativeValue = change.NewValue;
object value = typeof(T) switch
{
// Cast to .NET types
Type t when t == typeof(int) => ((NSNumber)nativeValue).Int32Value,
Type t when t == typeof(long) => ((NSNumber)nativeValue).Int64Value,
Type t when t == typeof(double) => ((NSNumber)nativeValue).DoubleValue,
Type t when t == typeof(bool) => ((NSNumber)nativeValue).BoolValue,
Type t when t == typeof(string) => ((NSString)nativeValue).ToString(),
_ => throw; // A wrapper class should be used in that case
};
// Set the value on our viewmodel
Property.SetValue(targeTViewModel, value);
}
/// Update the NSObject's value at the specified keypath with the property.
public void UpdateNSObject(object value)
{
// Box the value into a native UIKit type
// (If binding to more complex types than int/bool/strings, this will fail! A wrapper class should be used in that case)
var nativeValue = NSObject.FromObject(value);
// KVC operations need to run on the main thread
UIApplication.SharedApplication.BeginInvokeOnMainThread(() =>
{
// https://developer.apple.com/documentation/objectivec/nsobject/1418139-setvalue?language=objc
Object.SetValueForKeyPath(nativeValue, Keypath);
});
}
};
Then, our PropertyBinder
can just create instances of this object for each property you want to bind, and listen to notifications from both sides:
public class PropertyBinder<TViewModel>: IDisposable where TViewModel : INotifyPropertyChanged
{
// Per-property name bindings
private readonly Dictionary<string, IList<Binding>> _bindings = new();
// Per-property name
private readonly Dictionary<string, IDisposable> _observers = new();
private TViewModel _observableObject;
public PropertyBinder(TViewModel viewModel)
{
_observableObject = viewModel;
// Listen to C# property changes
_observableObject.PropertyChanged += OnObservablePropertyChanged;
}
public void Dispose()
{
if (_observableObject != null)
_observableObject.PropertyChanged -= OnObservablePropertyChanged;
// Dispose our observers
foreach (IDisposable observer in _observers.Values)
observer.Dispose();
}
// Bind a NSObject's keypath to a property of our observableObject
public void Bind<T>(NSObject obj, string keypath, string property, bool isTwoWay = false)
=> Bind<T>(obj, new NSString(keypath), property, isTwoWay);
private void OnObservablePropertyChanged(object sender, PropertyChangedEventArgs e)
{
var properties = new List<string>();
// The PropertyChanged event can indicate all properties on the object have changed by using either null or String.Empty as
// the property name in the PropertyChangedEventArgs.
if (string.IsNullOrEmpty(e.PropertyName))
{
properties.AddRange(_bindings.Keys);
}
else if (_bindings.ContainsKey(e.PropertyName)) // Only one property has changed
{
properties.Add(e.PropertyName);
}
foreach (var property in properties)
{
var bindings = _bindings.GetValueOrDefault(property);
foreach (var binding in bindings) // Update all bindings for this property
binding.UpdateNSObject(binding.Property.GetValue(_observableObject));
}
}
private void Bind<T>(NSObject obj, NSString keypath, string property, bool isTwoWay = false)
{
var propertyInfo = GetProperty(property);
var propertyValue = (T)propertyInfo.GetValue(_observableObject);
// Create C#/UIKit binding and add it to the list to keep track of it
var binding =
new Binding { Object = obj, Keypath = keypath, Property = propertyInfo };
if (_bindings.ContainsKey(property))
_bindings.GetValueOrDefault(property).Add(binding);
else
_bindings.Add(property, new List<Binding>(new[] { binding }));
// Set the initial value
binding.UpdateNSObject(propertyValue);
if (isTwoWay)
{
// Create an Observer with KVO to keep track of UIKit-side changes
// https://developer.apple.com/documentation/objectivec/nsobject/1412787-addobserver
var observer = obj.AddObserver(keypath, NSKeyValueObservingOptions.OldNew,
(c) => binding.UpdateProperty<T>(_observableObject, c));
// ^ Xamarin conveniently provides a version of this API call that
// takes a .NET event, so we can directly invoke our Binding object's Update.
_observers.Add(obj.Handle.ToString() + "-" + keypath, observer);
}
}
private PropertyInfo GetProperty(string propertyName)
{
// Small optimization to avoid calling reflection every time
if (_bindings.ContainsKey(propertyName))
return _bindings[propertyName].First().Property;
var propertyInfo = typeof(TViewModel).GetProperty(propertyName);
return propertyInfo ?? throw new ArgumentException($"Property {propertyName} not found on observable object {typeof(TViewModel).Name}");
}
}
And that's it!
Usage is then as simple as creating a PropertyBinder from your view code:
public RandomViewModel ViewModel = new() {Name = ""};
public UILabel NameLabel = new();
public PropertyBinder<PlaylistViewModel> Binder => new(ViewModel);
public override void AwakeFromNib()
{
base.AwakeFromNib();
// Bind keypath "text" of UILabel to the Name property of our viewmodel
Binder.Bind<string>(NameLabel, "text", nameof(ViewModel.Name));
}
wait fuck this is iOS
One final gotcha is that for KVO to work, aka for AddObserver to correctly create Observers and send notifications, your native UI controls must be KVO-compliant.
This is a funny Apple word to mean that the controls have to send notifications when their properties change - You can kind of think of it like DependencyProperties in XAML? It's a bit different though.
According to Apple documentation:
Not all classes are KVO-compliant for all properties. [...] Typically properties in Apple-supplied frameworks are only KVO-compliant if they are documented as such.
Basically, this means that since iOS didn't have Cocoa Bindings, they didn't give a single shit and most UIKit controls will not work with KVO.
So for Two-Way bindings, controls like UISwitch
won't notify our Binding
class when the user clicks on them! Doushio?
Well, the easiest solution is basically to do Apple's job in their place, and subclass the UIKit controls you need to make them comply.
Which is very easy as long as you only need a few properties:
[Register(nameof(KvoUISwitch))]
public class KvoUISwitch : UISwitch
{
public KvoUISwitch(IntPtr handle) : base(handle)
{
}
void ReleaseDesignerOutlets()
{
}
public override void AwakeFromNib()
{
base.AwakeFromNib();
// This will trigger when the switch value changes
AddTarget(NotifyChange, UIControlEvent.ValueChanged);
}
private void NotifyChange(object sender, EventArgs e)
{
// low-budget kvo compliance
WillChangeValue("on");
DidChangeValue("on");
}
}
There would be more complex things to add here (ICommand
support, Managed object wrapping in bindings, Converters aka NSValueTransformers
on the UIKit side), but I invite you to just peek at the Stylophone source if you're interested.
Have a nice day!