Wednesday, November 11, 2009

INotifyPropertyChanged and databinding in IronPython WPF

INotifyPropertyChanged is important interface for building WPF or Silverlight applications using M-V-VM concept (MSDN article).

In simple language, you have a Model which provides access to your data (e.g in database, files, web, etc.). Then you have a ViewModel that access data in the Model via Model's interface and provides data to a View which is XAML file with UI layout. Linkage between ViewModel and View is done by binding that utilizes PropertyChanged event to properly update all UI elements.

I have found two examples how to implement INotifyPropertyChanged interface in IronPython. The first one uses __setattr__ hook. Personally, I don't like it - it is not clear and easily readable code. The second one is better because it uses properties. But you have to write self.OnPropertyChanged("my_property_name") for every property. Not ideal.

That's why I sit down and write a notify_property:

class notify_property(property):

    def __init__(self, getter):
        def newgetter(slf):
            try:
                return getter(slf)
            except AttributeError:
                return None
        super(notify_property, self).__init__(newgetter)

    def setter(self, setter):
        def newsetter(slf, newvalue):
            oldvalue = self.fget(slf)
            if oldvalue != newvalue:
                setter(slf, newvalue)
                slf.OnPropertyChanged(setter.__name__)
        return property(
            fget=self.fget,
            fset=newsetter,
            fdel=self.fdel,
            doc=self.__doc__)

With this subclass I aimed several goals:

  • usage simple as @property decorator (actualy no other usage is possible as I implemented __init__ with just one parameter that must be the getter)
  • when property is on yet defined, it should return None
  • automaticaly handle PropertyChanged event when and only when property has changed

We also need to implement INotifyPropertyChanged interface in IronPython so we can call OnPropertyChanged method. See Overiding events in IronPython\Doc\dotnet-integration.rst to understand what means add_ and remove_ methods.

class NotifyPropertyChangedBase(INotifyPropertyChanged):
    PropertyChanged = None

    def __init__(self):
        self.PropertyChanged, self._propertyChangedCaller = pyevent.make_event()

    def add_PropertyChanged(self, value):
        self.PropertyChanged += value

    def remove_PropertyChanged(self, value):
        self.PropertyChanged -= value

    def OnPropertyChanged(self, propertyName):
        if self.PropertyChanged is not None:
            self._propertyChangedCaller(self, PropertyChangedEventArgs(propertyName))

Now we can implement a simple class with properties with change notification:

class DataObject(NotifyPropertyChangedBase):
    
    def __init__(self, size):
        super(DataObject, self).__init__()
        self.size = size

    @notify_property
    def size(self):
        return self._size

    @size.setter
    def size(self, value):
        self._size = value

You can see it is very easy - just like any other property in Python.

Finaly, let's put all together. When you run the code below, it shows a window with label, textbox and button. The label is updated as you type into the textbox and a message is written into the console as well. By default, the textbox is updated when it looses focus, so I have to change UpdateSourceTrigger to PropertyChanged. When you click the button, the value is reset. Note if you use type int instead of string the two-way bindign would not work.

notpropwpf.py

import clr
import System
clr.AddReference('PresentationFramework')
clr.AddReference('PresentationCore')

from System.Windows.Markup import XamlReader
from System.Windows import Application, Window
from System.ComponentModel import INotifyPropertyChanged, PropertyChangedEventArgs
import pyevent

XAML_str = """<window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Width="250" Height="62">
    <stackpanel x:Name="DataPanel" Orientation="Horizontal">
        <label Content="Size"/>
        <label Content="{Binding size}"/>
        <textbox x:Name="tbSize" Text="{Binding size, UpdateSourceTrigger=PropertyChanged}" />
        <button x:Name="Button" Content="Set Initial Value"></Button>
    </StackPanel>
</Window>"""

class notify_property(property):

    def __init__(self, getter):
        def newgetter(slf):
            #return None when the property does not exist yet
            try:
                return getter(slf)
            except AttributeError:
                return None
        super(notify_property, self).__init__(newgetter)

    def setter(self, setter):
        def newsetter(slf, newvalue):
            # do not change value if the new value is the same
            # trigger PropertyChanged event when value changes
            oldvalue = self.fget(slf)
            if oldvalue != newvalue:
                setter(slf, newvalue)
                slf.OnPropertyChanged(setter.__name__)
        return property(
            fget=self.fget,
            fset=newsetter,
            fdel=self.fdel,
            doc=self.__doc__)

class NotifyPropertyChangedBase(INotifyPropertyChanged):
    PropertyChanged = None

    def __init__(self):
        self.PropertyChanged, self._propertyChangedCaller = pyevent.make_event()

    def add_PropertyChanged(self, value):
        self.PropertyChanged += value

    def remove_PropertyChanged(self, value):
        self.PropertyChanged -= value

    def OnPropertyChanged(self, propertyName):
        if self.PropertyChanged is not None:
            self._propertyChangedCaller(self, PropertyChangedEventArgs(propertyName))

class ViewModel(NotifyPropertyChangedBase):
    
    def __init__(self):
        super(ViewModel, self).__init__()
        # must be string to two-way binding work correctly
        self.size = '10'

    @notify_property
    def size(self):
        return self._size

    @size.setter
    def size(self, value):
        self._size = value
        print 'Size changed to %r' % self.size

class TestWPF(object):

    def __init__(self):
        self._vm = ViewModel()
        self.root = XamlReader.Parse(XAML_str)
        self.DataPanel.DataContext = self._vm
        self.Button.Click += self.OnClick
        
    def OnClick(self, sender, event):
        # must be string to two-way binding work correctly
        self._vm.size = '10'

    def __getattr__(self, name):
        # provides easy access to XAML elements (e.g. self.Button)
        return self.root.FindName(name)

tw = TestWPF()
app = Application()
app.Run(tw.root)

You need pyevent.py from IronPython\Tutorial\ folder to run to example.

Unfortunately, this does not work in Silverlight, probably because the property is not .NET field. See next atricle for Silverlight version.

6 comments:

jaman said...

This post is a bit old, but I thought I'd feed back an enchancement I did based on your code:

In the class NotifyPropertyChangeBase I added the following:

def declare_notifiable(self, *symbols):
for symbol in symbols:
self.define_notifiable_property(symbol)

def define_notifiable_property(self, symbol):
dnp = """
import sys
sys.path.append(__file__)
@notify_property
def {0}(self):
return self._{0}

@{0}.setter
def {0}(self, value):
self._{0} = value
""".format(symbol)
d = globals()
exec dnp.strip() in d
setattr(self.__class__, symbol, d[symbol])

With that set, I can simply call:
self.define_notifiable_property("size")

This will do the @notify_property and @eize.setter items. Not a huge win when you only have one property, but a huge win when you can say self.define_notifiable_property("size", "weight", "age", "firstName", "lastName", "address").
With that you'd get all of those properties wired up to work with WPF.

Lukáš Čenovský said...

@jaman: nice enhancement.

Anonymous said...

thanks both for the super article and the enhancement as well

I just want to clarify for the very beginner how to write the new data class:
class ViewModel(NotifyPropertyChangedBase):
def __init__(self):
super(ViewModel, self).__init__()
self.declare_notifiable("size")
self.size="10"

Unknown said...

Thank you!!!

I'm using IronPython 2.7.3 and had to correct the Xaml to have the correct case. ex: TextBox instead of textbox.

Corrected Xaml here http://pastebin.com/BxrM9G6Y

It's amazing how few GOOD resources there are for WPF with IronPython. This MVVM stuff is getting exciting!

Anonymous said...

My Ironpython version 2.7.4 and int type is working Two-Way Binding Mode.

marbleMan said...

This crashes in pyevent, even after I fix the XAML like Kenneth Gustine suggests.
Using IronPython 2.7.10.

After I type into the text box to change the size from '10' to '3', the script crashes when I tap on the button. It is reproducible.


Size changed to '10'
Size changed to '4'
Size changed to '10'
Error: Invoke() takes exactly 2 arguments (1 given)

Traceback (most recent call last):
File ".\template_notify.py", line 101, in
app.Run(tw.root)
File ".\template_notify.py", line 93, in OnClick
self._vm.size = '10'
File ".\template_notify.py", line 44, in newsetter
slf.OnPropertyChanged(setter.__name__)
File ".\template_notify.py", line 65, in OnPropertyChanged
self._propertyChangedCaller(self, PropertyChangedEventArgs(propertyName))
File "\\mosaiqdbfs\MOSAIQ_APP\RayStationScripts-temp\expandRoi\pyevent.py", line 66, in __call__
ev(args)