Contents
For those that have read some of my other CodeProject.com articles, you will probably know that I am not shy about trying out new technologies. One good thing about that is that I generally share what I learn right here and this article is one of the hardest ones I've done, IMHO. This article is about how to create a peer-to-peer chat application using Windows Communication Foundation (WCF) and also how to make it look nice using Windows Presentation Foundation (WPF).
When I first started reading about WCF, the first place I looked was the MSDN WCF Samples (which I read a lot), but they weren't that great. I also found lots of chat apps based on the MSDN versions, which were no good, as they could not return the list of users within the chat application. I wanted to create a nice WPF styled app with the list of connected chatters.
So I hunted around a bit more and eventually came across a damn fine/brilliant article by Nikola Paljetak, which I have tailored for this article. I have OK'd this with Nikola and the original article content is here. To be honest, the original article was pure brilliance (it should be mentioned that Nikola is a Professor), but it took a while for me to get what was going on, as the code wasn't commented. I have now commented all code, so I think it will still make a very nice discussion/article for those who are new to WCF/WPF. I was totally new to WCF before this article, so if I can do it, so can all of you.
So that's what this article is all about. At the end of the article, I would hope you understand at least some of the key WCF areas and possibly be inspired enough to look at the WPF side of this article, also.
Before I start bombarding people with the inner mechanisms of the attached WPF/WCF application, shall we have a quick look at the finished product? There are 3 main areas within the attached demo application:
A login screen
A main window, from which the user can choose who to chat with:
And a window where chatters may openly chat:
The application is based on using Visual Studio 2005 with The Visual Studio Designer for WPF installed, or using Expression BLEND and Visual Studio 2005 combination, or Wordpad if you prefer to write stuff in that. Obviously, as it's a WPF/WCF application, you will also need the .NET 3.0 Framework. This application will cover the following concepts:
- WCF
- The new service orientated attributes
- The use of interfaces
- The use of callbacks
- Asynchronous delegates
- Creating the proxy
- WPF
- Styles
- Templates
- Animations
- Databinding
- Multithreading a WPF application
However, this application is not really that orientated to WPF, as that is covered in numerous other WPF articles at The Code Project. The WPF stuff is really just a wrapper around the WCF article, which is the real guts of this article. Although there is some nice WPF stuff going on, just to make the chat application look nicer than an ordinary console application. I will, however, discuss interesting bits of the WPF implementation.
- To run the code supplied with this article, you will need to install the May 2006 LINQ CTP, which is available here. There is a new March 2007 CTP available, but it's about 4GB for the full install (as its not just LINQ but the entire next generation of Visual Studio codenamed "Orcas") and quite fiddly, and probably going to change anyway. So, the May 2006 LINQ CTP will be OK for the purpose of what this article is trying to demonstrate.
- The .NET 3.0 Framework, which is available for download here.
In the attached demo application, we are trying to carry our the following functionality:
- Allow chatters to pick a name for themselves and pick an image (Avatar) that they would like to be presented by
- Allow the chatters to join a peer-to-peer chat
- Allow chatters to see who else is available to chat to
- Allow chatters to send private message
- Allow chatters to send global messages
- Allow chatters to leave the chat environment
- Make it all look pretty using WPF (not strictly a requirement for a chat app, but I like WPF, so humour me)
In order to achieve all of this I have developed 3 separate assemblies, which by the end I hope you will understand.
- ChatService.Dll: the WCF chat server, that allows chat clients to connect and is the main message router
- Common.Dll: is a simple serializable class which is used by both the ChatService.Dll and the WPFChatter.Dll files
- WPFChatter.Dll: the pretty WPF wrapper around the client WCF functions
There are a number of key concepts that were mentioned earlier that need to be explained in order for the full application (which covers a lot of ground) to be understood. So I'll just explain each of these a little bit at a time, so the final application should be a little easier to explain (well that's the idea anyway).
There are a number of new attributes that may be used with WCF to adorn our NET classes/interfaces, shown below are the ones that are used as part of the attached demo application.
ServiceContractAttribute
Indicates that an interface or a class defines a service contract in a Windows Communication Foundation (WCF) application. It has the following members:
Name | Description |
CallbackContract | Gets or sets the type of callback contract when the contract is a duplex contract. |
ConfigurationName | Gets or sets the name used to locate the service in an application configuration file. |
HasProtectionLevel | Gets a value that indicates whether the member has a protection level assigned. |
Name | Gets or sets the name for the <portType> element in Web Services Description Language (WSDL). |
Namespace | Gets or sets the namespace of the <portType> element in Web Services Description Language (WSDL). |
ProtectionLevel | Specifies whether the binding for the contract must support the value of the ProtectionLevel property. |
SessionMode | Gets or sets whether sessions are allowed, not allowed or required. |
TypeId | (Inherited from Attribute) |
See the MSDN article for more details.
OperationContractAttribute
Indicates that a method defines an operation that is part of a service contract in a Windows Communication Foundation (WCF) application. It has the following members:
Name | Description |
Action | Gets or sets the WS-Addressing action of the request message. |
AsyncPattern | Indicates that an operation is implemented asynchronously using a Begin<methodName> and End<methodName> method pair in a service contract. |
HasProtectionLevel | Gets a value that indicates whether the messages for this operation must be encrypted, signed, or both. |
IsInitiating | Gets or sets a value that indicates whether the method implements an operation that can initiate a session on the server (if such a session exists). |
IsOneWay | Gets or sets a value that indicates whether an operation returns a reply message. |
IsTerminating | Gets or sets a value that indicates whether the service operation causes the server to close the session after the reply message, if any, is sent. |
Name | Gets or sets the name of the operation. |
ProtectionLevel | Gets or sets a value that specifies whether the messages of an operation must be encrypted, signed, or both. |
ReplyAction | Gets or sets the value of the SOAP action for the reply message of the operation. |
TypeId | (Inherited from Attribute) |
See the MSDN article for more details
ServiceBehaviorAttribute
Specifies the internal execution behavior of a service contract implementation. It has the following members:
Name | Description |
AddressFilterMode | Gets or sets the AddressFilterMode that is used by the dispatcher to route incoming messages to the correct endpoint. |
AutomaticSessionShutdown | Specifies whether to automatically close a session when a client closes an output session. |
ConcurrencyMode | Gets or sets whether a service supports one thread, multiple threads, or reentrant calls. |
ConfigurationName | Gets or sets the value used to locate the service element in an application configuration file. |
IgnoreExtensionDataObject | Gets or sets a value that specifies whether to send unknown serialization data onto the wire. |
IncludeExceptionDetailInFaults | Gets or sets a value that specifies that general unhandled execution exceptions are to be converted into a System.ServiceModel.FaultException of type System.ServiceModel.ExceptionDetail and sent as a fault message. Set this to true only during development to troubleshoot a service. |
InstanceContextMode | Gets or sets the value that indicates when new service objects are created. |
MaxItemsInObjectGraph | Gets or sets the maximum number of items allowed in a serialized object. |
Name | Gets or sets the value of the name attribute in the service element in Web Services Description Language (WSDL). |
Namespace | Gets or sets the value of the target namespace for the service in Web Services Description Language (WSDL). |
ReleaseServiceInstanceOnTransactionComplete | Gets or sets a value that specifies whether the service object is released when the current transaction completes. |
TransactionAutoCompleteOnSessionClose | Gets or sets a value that specifies whether pending transactions are completed when the current session closes without error. |
TransactionIsolationLevel | Specifies the transaction isolation level for new transactions created inside the service, and incoming transactions flowed from a client. |
TransactionTimeout | Gets or sets the period within which a transaction must complete. |
TypeId | (Inherited from Attribute) |
UseSynchronizationContext | Gets or sets a value that specifies whether to use the current synchronization context to choose the thread of execution. |
ValidateMustUnderstand | Gets or sets a value that specifies whether the system or the application enforces SOAP MustUnderstand header processing. |
See the MSDN article or more details. Here is an example of how these new WCF attributes are used within the demo application, Service project -> ChatService.cs.
[ServiceContract(SessionMode = SessionMode.Required,
CallbackContract = typeof(IChatCallback))]
interface IChat
{
[OperationContract(IsOneWay = true, IsInitiating = false,
IsTerminating = false)]
void Say(string msg);
[OperationContract(IsOneWay = true, IsInitiating = false,
IsTerminating = false)]
void Whisper(string to, string msg);
[OperationContract(IsOneWay = false, IsInitiating = true,
IsTerminating = false)]
Person[] Join(Person name);
[OperationContract(IsOneWay = true, IsInitiating = false,
IsTerminating = true)]
void Leave();
}
"The notion of a contract is the key to building a WCF service. Those of you that have a background in classic DCOM or COM technologies might be surprised to know that WCF contracts are expressed using interface-based programming techniques (everything old is new again!). While not mandatory, the vast amount your WCF applications will begin by defining a set of .NET interface types that are used to represent the set of members a given WCF type will support. Specifically speaking, interfaces that represent a WCF contract are termed service contracts. The classes (or structures) that implement them are termed service types."
Pro C# with .NET3.0, Apress. Andrew Troelsen
So there you are -- that's what a nice new book says -- but what does this look like to us in code? Well the actual ChatService.cs class implements the IChat
interface just mentioned, and so looks like this:
[ServiceBehavior(InstanceContextMode = InstanceContextMode.PerSession,
ConcurrencyMode = ConcurrencyMode.Multiple)]
public class ChatService : IChat
{
}
Recall earlier, when I mentioned the ServiceContractAttribute
I also mentioned that one of its properties was a CallBackContract
that was defined as, which allows the WCF service to call the client back. This new WCF method is very nice. I remember trying to do something like this with remoting and events back to the client, and it was not fun at all. I much prefer this method. Let's have a look. Recall that the original service contract interface was declared like this:
[ServiceContract(SessionMode = SessionMode.Required,
CallbackContract = typeof(IChatCallback))]
interface IChat
{
....
}
Well we still need to define an interface to allow the callback to work, so an example of this may be (as done in the demo app):
interface IChatCallback
{
[OperationContract(IsOneWay = true)]
void Receive(Person sender, string message);
[OperationContract(IsOneWay = true)]
void ReceiveWhisper(Person sender, string message);
[OperationContract(IsOneWay = true)]
void UserEnter(Person person);
[OperationContract(IsOneWay = true)]
void UserLeave(Person person);
}
"The .NET Framework allows you to call any method asynchronously. To do this you define a delegate with the same signature as the method you want to call; the common language runtime automatically defines BeginInvoke and EndInvoke methods for this delegate, with the appropriate signatures.
The BeginInvoke method initiates the asynchronous call. It has the same parameters as the method you want to execute asynchronously, plus two additional optional parameters. The first parameter is an AsyncCallback delegate that references a method to be called when the asynchronous call completes. The second parameter is a user-defined object that passes information into the callback method. BeginInvoke returns immediately and does not wait for the asynchronous call to complete. BeginInvoke returns an IAsyncResult, which can be used to monitor the progress of the asynchronous call.
The EndInvoke method retrieves the results of the asynchronous call. It can be called any time after BeginInvoke; if the asynchronous call has not completed, EndInvoke blocks the calling thread until it completes. "
Calling Synchronous Methods Asynchronously
In order to for the client to communicate with a WCF service, we need a proxy object. This can be quite a daunting task (and a little complicated to be honest). Luckily like a lot of things in .NET 3.0/3.5 there are tools provided to make our lives easier (you still have to know about them though), and WCF is no different. It has a little tool called "svcutil" which comes to the rescue.
So how do we create a proxy for our little WCF service (ChatService.exe for the demo app) using svcutil. Well I have read one thing that said you should be able to just start the WCF service, point svcutil at the running WCF service, and be able to create the client proxy that way. But I have to say, I could NOT get that to work at all. It seems to a common gripe, if you search the internet. So the way I got it to work was as follows:
- Open a visual studio command prompt, and change to the directory that contains the WCF service
- Run the following command line:
svcutil <YOUR_SERVICE.exe>
- This will list a few files, namely *.wsdl, and *.xsd, and a schemas.microsoft.com.2003.10.Serialization.xsd file
- Next you need to run the following command line:
svcutil *.wsdl *.xsd /language:C# /out:MyProxy.cs /config:app.config
- You now have 2 new client files, MyProxy.cs and app.config, so you can copy these to your client application
To give you an idea of what svcutil.exe produces in terms of client files, let's have a look. Here is the MyProxy.cs C# file that was auto-generated by svcutil.exe.
namespace Common
{
using System.Runtime.Serialization;
[System.CodeDom.Compiler.GeneratedCodeAttribute(
"System.Runtime.Serialization", "3.0.0.0")]
[System.Runtime.Serialization.DataContractAttribute()]
public partial class Person : object,
System.Runtime.Serialization.IExtensibleDataObject
{
private System.Runtime.Serialization.ExtensionDataObject
extensionDataField;
private string ImageURLField;
private string NameField;
public System.Runtime.Serialization.ExtensionDataObject ExtensionData
{
get
{
return this.extensionDataField;
}
set
{
this.extensionDataField = value;
}
}
[System.Runtime.Serialization.DataMemberAttribute()]
public string ImageURL
{
get
{
return this.ImageURLField;
}
set
{
this.ImageURLField = value;
}
}
[System.Runtime.Serialization.DataMemberAttribute()]
public string Name
{
get
{
return this.NameField;
}
set
{
this.NameField = value;
}
}
}
}
[System.CodeDom.Compiler.GeneratedCodeAttribute("System.ServiceModel",
"3.0.0.0")]
[System.ServiceModel.ServiceContractAttribute(ConfigurationName="IChat",
CallbackContract=typeof(IChatCallback),
SessionMode=System.ServiceModel.SessionMode.Required)]
public interface IChat
{
[System.ServiceModel.OperationContractAttribute(IsOneWay=true,
IsInitiating=false, Action="http://tempuri.org/IChat/Say")]
void Say(string msg);
[System.ServiceModel.OperationContractAttribute(IsOneWay=true,
IsInitiating=false, Action="http://tempuri.org/IChat/Whisper")]
void Whisper(string to, string msg);
[System.ServiceModel.OperationContractAttribute(
Action=http:
ReplyAction="http://tempuri.org/IChat/JoinResponse")]
Common.Person[] Join(Common.Person name);
[System.ServiceModel.OperationContractAttribute(IsOneWay=true,
IsTerminating=true, IsInitiating=false,
Action="http://tempuri.org/IChat/Leave")]
void Leave();
}
[System.CodeDom.Compiler.GeneratedCodeAttribute("System.ServiceModel",
"3.0.0.0")]
public interface IChatCallback
{
[System.ServiceModel.OperationContractAttribute(IsOneWay=true,
Action="http://tempuri.org/IChat/Receive")]
void Receive(Common.Person sender, string message);
[System.ServiceModel.OperationContractAttribute(IsOneWay=true,
Action="http://tempuri.org/IChat/ReceiveWhisper")]
void ReceiveWhisper(Common.Person sender, string message);
[System.ServiceModel.OperationContractAttribute(IsOneWay=true,
Action="http://tempuri.org/IChat/UserEnter")]
void UserEnter(Common.Person person);
[System.ServiceModel.OperationContractAttribute(IsOneWay=true,
Action="http://tempuri.org/IChat/UserLeave")]
void UserLeave(Common.Person person);
}
[System.CodeDom.Compiler.GeneratedCodeAttribute("System.ServiceModel",
"3.0.0.0")]
public interface IChatChannel : IChat, System.ServiceModel.IClientChannel
{
}
[System.Diagnostics.DebuggerStepThroughAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("System.ServiceModel",
"3.0.0.0")]
public partial class ChatClient :
System.ServiceModel.DuplexClientBase<IChat>,
IChat
{
public ChatClient(System.ServiceModel.InstanceContext callbackInstance) :
base(callbackInstance)
{
}
public ChatClient(System.ServiceModel.InstanceContext callbackInstance,
string endpointConfigurationName) :
base(callbackInstance, endpointConfigurationName)
{
}
public ChatClient(System.ServiceModel.InstanceContext callbackInstance,
string endpointConfigurationName, string remoteAddress) :
base(callbackInstance, endpointConfigurationName, remoteAddress)
{
}
public ChatClient(System.ServiceModel.InstanceContext callbackInstance,
string endpointConfigurationName,
System.ServiceModel.EndpointAddress remoteAddress) :
base(callbackInstance, endpointConfigurationName, remoteAddress)
{
}
public ChatClient(System.ServiceModel.InstanceContext callbackInstance,
System.ServiceModel.Channels.Binding binding,
System.ServiceModel.EndpointAddress remoteAddress) :
base(callbackInstance, binding, remoteAddress)
{
}
public void Say(string msg)
{
base.Channel.Say(msg);
}
public void Whisper(string to, string msg)
{
base.Channel.Whisper(to, msg);
}
public Common.Person[] Join(Common.Person name)
{
return base.Channel.Join(name);
}
public void Leave()
{
base.Channel.Leave();
}
}
And here is the client App.Config that was auto-generated by svcutil.exe.
="1.0"="utf-8"
<configuration>
<system.serviceModel>
<bindings>
<basicHttpBinding>
<binding name="DefaultBinding_IChat" closeTimeout="00:01:00"
openTimeout="00:01:00" receiveTimeout="00:10:00"
sendTimeout="00:01:00"
allowCookies="false" bypassProxyOnLocal="false"
hostNameComparisonMode="StrongWildcard"
maxBufferSize="65536" maxBufferPoolSize="524288"
maxReceivedMessageSize="65536"
messageEncoding="Text" textEncoding="utf-8"
transferMode="Buffered"
useDefaultWebProxy="true">
<readerQuotas maxDepth="32"
maxStringContentLength="8192"
maxArrayLength="16384"
maxBytesPerRead="4096"
maxNameTableCharCount="16384" />
<security mode="None">
<transport clientCredentialType="None"
proxyCredentialType="None" realm="" />
<message clientCredentialType="UserName"
algorithmSuite="Default" />
</security>
</binding>
</basicHttpBinding>
</bindings>
<client>
<endpoint binding="basicHttpBinding"
bindingConfiguration="DefaultBinding_IChat"
contract="IChat" name="DefaultBinding_IChat_IChat" />
</client>
</system.serviceModel>
</configuration>
So as you can see, these files can simply be used straightaway within your own client application to communicate with the WCF service. But wait, we are still not finished with svcutil.exe. Recall that I mentioned asynchronous delegates -- so why did I do that? Well the svcutil.exe also allows us to create asynchronous proxy code, using one of the command line switches. To do this, we use the following command line (notice the /a
option):
svcutil *.wsdl *.xsd /a /language:C# /out:MyProxy.cs /config:app.config
...instead of:
svcutil *.wsdl *.xsd /language:C# /out:MyProxy.cs /config:app.config
...which we used previously. This will then change the format of the C# (or VB .NET) file we get out. What we get now for each WCF service method is an asynchronous one. So, we would get the following:
[System.ServiceModel.OperationContractAttribute(IsOneWay=true,
IsInitiating=false, Action="http://tempuri.org/IChat/Say")]
void Say(string msg);
[System.ServiceModel.OperationContractAttribute(IsOneWay=true,
IsInitiating=false, AsyncPattern=true,
Action="http://tempuri.org/IChat/Say")]
System.IAsyncResult BeginSay(string msg, System.AsyncCallback callback,
object asyncState);
void EndSay(System.IAsyncResult result);
...instead of:
[System.ServiceModel.OperationContractAttribute(IsOneWay=true,
IsInitiating=false, Action="http://tempuri.org/IChat/Say")]
void Say(string msg);
Hopefully you can see where this ties in with the WCF: Asynchronous Delegates section mentioned earlier. But just to be sure, here's a more detailed description of what's going on. Using the /a
switch of svcutil.exe, you can generate a proxy that contains asynchronous methods in addition to the synchronous ones. For each operation in the original contract, the asynchronous proxy and contract will contain two additional methods of this form:
The OperationContractAttribute
offers the AsyncPattern
Boolean property. AsyncPattern
only has meaning on the client-side copy of the contract. You can only set AsyncPattern
to true
on a method with a Begin<Operation>( )
-compatible signature. The defining contract must also have a matching method with an End<Operation>( )
-compatible signature. These requirements are verified at proxy load time. What AsyncPattern
does is bind the underlying synchronous method with the Begin
/End
pair, and correlates the synchronous execution with the asynchronous one.
When the client invokes a method of the form Begin<Operation>( )
with AsyncPattern
set to true
, it tells WCF not to try to directly invoke a method by that name on the service. Instead, it will use a thread from the thread pool to synchronously call the underlying method (identified by the Action
name). The synchronous call will block the thread from the thread pool, not the calling client. The client will only be blocked for the slightest moment it takes to dispatch the call request to the thread pool. The reply method of the synchronous invocation is correlated with the End<Operation>( )
method.
As the attached demo application makes use of asynchronous methods for the Join
operation, asynchronous delegates are using within the code to achieve this.
As with all .NET applications, WCF applications allow the application to be configured via a configuration file. This will be discussed later on, for now you just need to know that the following items may be configured in an App.Config file for a WCF application:
- Service addresses
- Service type
- Behavior configuration
- Endpoints
- Binding types
- Service security
WPF styles and templates allow us to change how standard components look. This is quite a well documented feature, but what I will say is that by using a little bit of styling one is able to convert a rather plain ListView
into a ListView
that looks like the figure shown below. Quite nice, I think. So how is this done? Well, it's all down to styling. The figure below shows a standard ListView
item which is styled and has some custom data templates assigned. Each item in the ListView
is actually a Common.Person
object, which will be discussed later.
All that has been done is that I have applied a style to a standard .NET ListView
control. Here is the XAML that does this. However, I say I am not going to dwell on these WPF features, as they are well documented and not really part of the main thrust of this article. I just wanted those people reading this that hadn't come across WPF before, to know what can be done with it.
<Style x:Key="ListViewContainer" TargetType="{x:Type ListViewItem}">
<Setter Property="FontWeight" Value="Normal"/>
<Setter Property="Foreground" Value="#FF000000"/>
<Setter Property="FontFamily" Value="Agency FB"/>
<Setter Property="FontSize" Value="15"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="true">
<Setter Property="Foreground" Value="Black" />
<Setter Property="Background">
<Setter.Value>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<LinearGradientBrush.GradientStops>
<GradientStop Color="#D88" Offset="0"/>
<GradientStop Color="#D31" Offset="1"/>
</LinearGradientBrush.GradientStops>
</LinearGradientBrush>
</Setter.Value>
</Setter>
<Setter Property="Cursor" Value="Hand"/>
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsSelected" Value="true" />
<Condition Property="Selector.IsSelectionActive" Value="true" />
</MultiTrigger.Conditions>
<Setter Property="Background">
<Setter.Value>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<LinearGradientBrush.GradientStops>
<GradientStop Color="#0E4791" Offset="0"/>
<GradientStop Color="#468DE2" Offset="1"/>
</LinearGradientBrush.GradientStops>
</LinearGradientBrush>
</Setter.Value>
</Setter>
<Setter Property="Foreground" Value="Black" />
</MultiTrigger>
</Style.Triggers>
</Style>
<DataTemplate x:Key="noTextHeaderTemplate"/>
<DataTemplate x:Key="textCellTemplate">
<TextBlock Margin="10,0,0,0" Text="{Binding}"
VerticalAlignment="Center"/>
</DataTemplate>
<DataTemplate x:Key="imageCellTemplate">
<Border CornerRadius="2,2,2,2" Width="40" Height="40"
Background="#FFFFC934" BorderBrush="#FF000000" Margin="3,3,3,3">
<Image HorizontalAlignment="Center" VerticalAlignment="Center"
Width="Auto" Height="Auto"
Source="{Binding Path=ImageURL}" Stretch="Fill"
Margin="2,2,2,2"/>
</Border>
</DataTemplate>
.....
.....
<ListView DockPanel.Dock="Bottom" Margin="0,-10,0,0"
VerticalAlignment="Bottom"
x:Name="lstChatters" SelectionMode="Single"
ItemContainerStyle="{StaticResource ListViewContainer}"
Background="{x:Null}" BorderBrush="#FFFFFBFB" Foreground="#FFB5B5B5"
Opacity="1" BorderThickness="2,2,2,2"
HorizontalAlignment="Stretch" Width="Auto" Height="Auto">
<ListView.View>
<GridView>
<GridView.ColumnHeaderContainerStyle>
<Style TargetType="GridViewColumnHeader">
<Setter Property="Visibility" Value="Hidden" />
<Setter Property="Height" Value="0" />
</Style>
</GridView.ColumnHeaderContainerStyle>
<GridViewColumn Header="Image"
HeaderTemplate="{StaticResource noTextHeaderTemplate}"
Width="100" CellTemplate="{StaticResource imageCellTemplate}"/>
<GridViewColumn DisplayMemberBinding="{Binding Path=Name}"
Header="First Name"
HeaderTemplate="{StaticResource textCellTemplate}" Width="100"/>
</GridView>
</ListView.View>
</ListView>
Animations are another element of WPF (again well documented, so I'll not go into it too much). I have not gone too overboard with animations in the demo application, but I do use animation twice (because one simply has to if they are developing WPF stuff).
Once to load the ChatControl
and once to hide the ChatControl
. The only thing that is special is the way that I am using animation. They are part of the Window1.xaml
but the triggers are performed in code behind logic. As this may be useful to some people I'll give a small example.
In Window1.xaml
I have declared an animation as follows, the one shown below loads the ChatControl
by growing it from 0 X/Y scale to 100% X/Y scale over a period of 1 second, and is triggered when a user clicks a ListView
item.
<Storyboard x:Key="showChatWindow">
<DoubleAnimationUsingKeyFrames BeginTime="00:00:00"
Storyboard.TargetName="ChatControl"
Storyboard.TargetProperty="(UIElement.RenderTransform).(
TransformGroup.Children)[0].(ScaleTransform.ScaleX)">
<SplineDoubleKeyFrame KeyTime="00:00:00" Value="0"/>
<SplineDoubleKeyFrame KeyTime="00:00:001" Value="1"/>
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames BeginTime="00:00:00"
Storyboard.TargetName="ChatControl"
Storyboard.TargetProperty="(UIElement.RenderTransform).(
TransformGroup.Children)[0].(ScaleTransform.ScaleY)">
<SplineDoubleKeyFrame KeyTime="00:00:00" Value="0"/>
<SplineDoubleKeyFrame KeyTime="00:00:001" Value="1"/>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
I trigger this animation directly from code-behind. So how do I do that? Well, let's have a look at the code, shall we? It's fairly easy; the code to do that is as follows:
((Storyboard)this.Resources["showChatWindow"]).Begin(this);
The styled ListView
that I am using within Window1.xaml
utilizes data binding in order to bind a List
of Person
objects. This is done by using Templates as already shown in the WPF : Styles/Templates section. But just in case you're not so sure about all this WPF stuff, what actually happens is that I use a DataTemplate
which specifies just how the ListView
will display its data. To do that, I define the following DataTemplate
s and these DataTemplate
s include the Binding
values. This allows a collection of Person
objects to be bound to the ListView.
<DataTemplate x:Key="textCellTemplate">
<TextBlock Margin="10,0,0,0" Text="{Binding}" VerticalAlignment="Center"/>
</DataTemplate>
<DataTemplate x:Key="imageCellTemplate">
<Border CornerRadius="2,2,2,2" Width="40" Height="40"
Background="#FFFFC934" BorderBrush="#FF000000" Margin="3,3,3,3">
<Image HorizontalAlignment="Center" VerticalAlignment="Center"
Width="Auto"
Height="Auto" Source="{Binding Path=ImageURL}" Stretch="Fill"
Margin="2,2,2,2"/>
</Border>
</DataTemplate>
Threading in WPF is quite similar to .NET 2.0/Win forms, you still have the issue of threads that are not on the same owner thread as a UI component needing to be marshaled to the correct thread. The only difference is the keywords. For example, in .NET 2.0, one would probably have done:
if (this.InvokeRequired)
{
this.Invoke(new EventHandler(delegate
{
progressBar1.Value = e.ProgressValue;
}));
}
else
{
progressBar1.Value = e.ProgressValue;
}
...while in WPF we would (and I do) use the following syntax. Note : CheckAccess()
is marked as [Browsable(false)]
so don't be expecting to see it using Intellisense. However, it does work.
private delegate void ProxySingleton_ProxyEvent_Delegate(
object sender, ProxyEventArgs e);
private void ProxySingleton_ProxyEvent(object sender,
ProxyEventArgs e)
{
if (!this.Dispatcher.CheckAccess())
{
this.Dispatcher.BeginInvoke(DispatcherPriority.Normal,
new ProxySingleton_ProxyEvent_Delegate(
ProxySingleton_ProxyEvent),
sender, new object[] { e });
return;
}
foreach (Person person in e.list)
{
lstChatters.Items.Add(person);
}
this.ChatControl.AppendText("Connected at " +
DateTime.Now.ToString() + " with user name " +
currPerson.Name + Environment.NewLine);
}
Well, you know what, if you've got to this point without falling asleep, I think you're ready to deal with the inner workings of the attached demo application(s). It should be child's play now, as we've covered all the key elements. There's nothing new to say, apart from how the demo app uses all this stuff (though, some of it we've already discussed). So it should be just a question of explaining it all now.
Well how about we start with a sequence diagram (I know UML isn't that great for distributed apps, so I've annotated it with comments, but I hope you get the general idea).
I apologize that the text on this diagram is so small, but that's down to the restrictions on image sizes here at The Code Project. I'll even give you some class diagrams, for those that prefer them. Remember that there are 3 separate assemblies (ChatService / Common / WPFChatter), which I talked about earlier:
ChatService
In order for this service to work correctly, there is a special configuration file. It could also have been done in code, but App.Config is just more flexible. So, let's have a look at the ChatService.exe App.Config, shall we? Well, it looks like this:
="1.0"="utf-8"
<configuration>
<appSettings>
<add key="addr" value="net.tcp://localhost:22222/chatservice" />
</appSettings>
<system.serviceModel>
<services>
<service name="Chatters.ChatService" behaviorConfiguration="MyBehavior">
<endpoint address=""
binding="netTcpBinding"
bindingConfiguration="DuplexBinding"
contract="Chatters.IChat" />
</service>
</services>
<behaviors>
<serviceBehaviors>
<behavior name="MyBehavior">
<serviceThrottling maxConcurrentSessions="10000" />
</behavior>
</serviceBehaviors>
</behaviors>
<bindings>
<netTcpBinding>
<binding name="DuplexBinding" sendTimeout="00:00:01">
<reliableSession enabled="true" />
<security mode="None" />
</binding>
</netTcpBinding>
</bindings>
</system.serviceModel>
</configuration>
As you can see, this App.Config file contains all the information required to enable the service to operate. WCF supports a lot of different binding options, such as:
Binding type | Description |
BasicHttpBinding | A binding that is suitable for communicating with WS-Basic Profile conformant Web Services, for example, ASMX-based services. This binding uses HTTP as the transport and Text/XML as the default message encoding. |
WSHttpBinding | A secure and interoperable binding that is suitable for non-duplex service contracts. |
WSDualHttpBinding | A secure and interoperable binding that is suitable for duplex service contracts or communication through SOAP intermediaries. |
WSFederationHttpBinding | A secure and interoperable binding that supports the WS-Federation protocol, enabling organizations that are in a federation to efficiently authenticate and authorize users. |
NetTcpBinding | A secure and optimized binding suitable for cross-machine communication between WCF applications. |
NetNamedPipeBinding | A secure, reliable, optimized binding that is suitable for on-machine communication between WCF applications.
|
NetMsmqBinding | A queued binding that is suitable for cross-machine communication between WCF applications. |
NetPeerTcpBinding | A binding that enables secure, multi-machine communication.
|
MsmqIntegrationBinding | A binding that is suitable for cross-machine communication between a WCF application and existing MSMQ applications. |
For the demo application, I am using NetTcpBinding
. For more information on bindings in WCF applications, you can see this link.
Common
This is a very simple class that is used by the ChatService
and WPFChatter
assemblies. This class represents a single chatter and may be sent over the WCF channel due the special WCF annotations that have been applied to this class. The entire class is listed below, as it's not so big:
using System
using System.Collections.Generic;
using System.Text;
using System.ServiceModel;
using System.Runtime.Serialization;
using System.ComponentModel;
namespace Common
{
#region Person class
[DataContract]
public class Person : INotifyPropertyChanged
{
#region Instance Fields
private string imageURL;
private string name;
public event PropertyChangedEventHandler PropertyChanged;
#endregion
#region Ctors
public Person()
{
}
public Person(string imageURL, string name)
{
this.imageURL = imageURL;
this.name = name;
}
#endregion
#region Public Properties
[DataMember]
public string ImageURL
{
get { return imageURL; }
set
{
imageURL = value;
OnPropertyChanged("ImageURL");
}
}
[DataMember]
public string Name
{
get { return name; }
set
{
name = value;
OnPropertyChanged("Name");
}
}
#endregion
#region OnPropertyChanged (for correct well behaved databinding)
protected void OnPropertyChanged(string propValue)
{
PropertyChangedEventHandler handler = PropertyChanged;
if (handler != null)
{
handler(this, new PropertyChangedEventArgs(propValue));
}
}
#endregion
}
#endregion
}
As you can see, this class has some special attributes which still haven't been talked about. It also inherits from a strange interface. So what's that all about, then? Well, [DataContract]
specifies that the type defines or implements a data contract and is serializable by a serializer such as DataContractSerializer
. The INotifyPropertyChanged
interface is a special WPF interface that allows the class to notify and binding container of a change in a value. Thus, when a property is changed, the binding container will be informed. This applies to the Window1.xaml ListView
in my case, as described in the WPF: Databinding section.
WPFChatter
The app.config file and WCF proxy were created using the WCF scvutil.exe tool, as mentioned in the WCF: Creating the Proxy section. The finished WPF application is as shown below:
What I'm going to do now is explain a bit about how all the WCF/WPF stuff works in conjunction with each other, using the attached demo app. I think the best place to start would be to describe the actual ChatService
(the server end) and why it's adorned the way it is. I will not go into all the inner workings of the ChatService.cs file just yet, as this will be visited later.
ChatService.cs
[ServiceContract(SessionMode = SessionMode.Required,
CallbackContract = typeof(IChatCallback))]
interface IChat
{
[OperationContract(IsOneWay = true, IsInitiating = false,
IsTerminating = false)]
void Say(string msg);
[OperationContract(IsOneWay = true, IsInitiating = false,
IsTerminating = false)]
void Whisper(string to, string msg);
[OperationContract(IsOneWay = false, IsInitiating = true,
IsTerminating = false)]
Person[] Join(Person name);
[OperationContract(IsOneWay = true, IsInitiating = false,
IsTerminating = true)]
void Leave();
}
interface IChatCallback
{
[OperationContract(IsOneWay = true)]
void Receive(Person sender, string message);
[OperationContract(IsOneWay = true)]
void ReceiveWhisper(Person sender, string message);
[OperationContract(IsOneWay = true)]
void UserEnter(Person person);
[OperationContract(IsOneWay = true)]
void UserLeave(Person person);
}
public class ChatEventArgs : EventArgs
{
public MessageType msgType;
public Person person;
public string message;
}
[ServiceBehavior(InstanceContextMode = InstanceContextMode.PerSession,
ConcurrencyMode = ConcurrencyMode.Multiple)]
public class ChatService : IChat
{
...
}
There are several things to note here:
- There is an
IChat
interface which is implemented by the ChatService
(server end) class. This is in order that the ChatService
implements a common Chat interface that is known at compile time. Each of the interface methods are adorned with new WCF [OperationContract]
attributes. I discussed all the possible values for this attribute earlier in this article. The general rule of thumb here is that if the method needs to return a value of IsOneWay = true
. Otherwise, it will be IsOneWay = false
. All these methods are also marked with the IsInitiating
and IsTerminating
properties. As the Join
method is initialising the service, this is marked with IsInitiating = true
and as the Leave
method is quitting the service, this is marked with IsTerminating = true
. - There is also another
IChatCallBack
interface included within the ChatService
class. This is to allow the ChatService
to make callbacks to the clients using a well-known interface that is also known at compile time. It can be seen that the server-implemented IChat
interface actually includes extra annotation to state whether there is a callback required. Let's have a look at [ServiceContract(SessionMode = SessionMode.Required, CallbackContract = typeof(IChatCallback))]
. Notice how the CallbackContract
is specified as being of type IChatCallback
. That's how the WCF service knows how to communicate back to the client. Obviously, just having the CallbackContract
property and not the actual IChatCallback
interface is not enough. You need both. - Lastly, we need to note that the actual
ChatService
is also adorned with extra attributes that allow the service itself to be managed correctly. I am using InstanceContextMode = InstanceContextMode.PerSession
. PerSession
instructs the service application to create a new service object when a new communication session is established between a client and the service application. Subsequent calls in the same session are handled by the same object. I am also using ConcurrencyMode = ConcurrencyMode.Multiple
. A value of Multiple
means that service objects can be executed by multiple threads at any one time. In this case, you must ensure thread safety.
So that's the ChatService
's WCF attributes explained, but what about the client-side code? Remember that we used svcutil.exe to create a proxy object the way I previously described. So I am not going to go into that in too much detail. However, the full proxy code (ChatService.cs, in the WPFChatter
project in the demo code) is shown below. With the descriptions just given, and the previously mentioned attribute tables, you should be able to see what's going on I hope.
using Common;
#region IChat interface
[System.CodeDom.Compiler.GeneratedCodeAttribute("System.ServiceModel",
"3.0.0.0")]
[System.ServiceModel.ServiceContractAttribute(CallbackContract = typeof(
IChatCallback), SessionMode = System.ServiceModel.SessionMode.Required)]
public interface IChat
{
[System.ServiceModel.OperationContractAttribute(AsyncPattern = true,
Action = http:
ReplyAction = "http://tempuri.org/IChat/JoinResponse")]
System.IAsyncResult BeginJoin(Person name, System.AsyncCallback callback,
object asyncState);
Person[] EndJoin(System.IAsyncResult result);
[System.ServiceModel.OperationContractAttribute(IsOneWay = true,
IsInitiating = false, Action = "http://tempuri.org/IChat/Leave")]
void Leave();
[System.ServiceModel.OperationContractAttribute(IsOneWay = true,
IsInitiating = false, Action = "http://tempuri.org/IChat/Say")]
void Say(string msg);
[System.ServiceModel.OperationContractAttribute(IsOneWay = true,
IsInitiating = false, Action = "http://tempuri.org/IChat/Whisper")]
void Whisper(string to, string msg);
}
#endregion
#region IChatCallback interface
[System.CodeDom.Compiler.GeneratedCodeAttribute("System.ServiceModel",
"3.0.0.0")]
public interface IChatCallback
{
[System.ServiceModel.OperationContractAttribute(IsOneWay = true,
Action = "http://tempuri.org/IChat/Receive")]
void Receive(Person sender, string message);
[System.ServiceModel.OperationContractAttribute(IsOneWay = true,
Action = "http://tempuri.org/IChat/ReceiveWhisper")]
void ReceiveWhisper(Person sender, string message);
[System.ServiceModel.OperationContractAttribute(IsOneWay = true,
Action = "http://tempuri.org/IChat/UserEnter")]
void UserEnter(Person person);
[System.ServiceModel.OperationContractAttribute(IsOneWay = true,
Action = "http://tempuri.org/IChat/UserLeave")]
void UserLeave(Person person);
}
#endregion
#region IChatChannel interface
[System.CodeDom.Compiler.GeneratedCodeAttribute("System.ServiceModel",
"3.0.0.0")]
public interface IChatChannel : IChat, System.ServiceModel.IClientChannel
{
}
#endregion
#region ChatProxy class
[System.CodeDom.Compiler.GeneratedCodeAttribute("System.ServiceModel",
"3.0.0.0")]
public partial class ChatProxy : System.ServiceModel.DuplexClientBase<IChat>,
IChat
{
public ChatProxy(System.ServiceModel.InstanceContext callbackInstance)
:
base(callbackInstance)
{
}
public ChatProxy(System.ServiceModel.InstanceContext callbackInstance,
string endpointConfigurationName)
:
base(callbackInstance, endpointConfigurationName)
{
}
public ChatProxy(System.ServiceModel.InstanceContext callbackInstance,
string endpointConfigurationName, string remoteAddress)
:
base(callbackInstance, endpointConfigurationName, remoteAddress)
{
}
public ChatProxy(System.ServiceModel.InstanceContext callbackInstance,
string endpointConfigurationName,
System.ServiceModel.EndpointAddress remoteAddress)
:
base(callbackInstance, endpointConfigurationName, remoteAddress)
{
}
public ChatProxy(System.ServiceModel.InstanceContext callbackInstance,
System.ServiceModel.Channels.Binding binding,
System.ServiceModel.EndpointAddress remoteAddress)
:
base(callbackInstance, binding, remoteAddress)
{
}
public System.IAsyncResult BeginJoin(Person name,
System.AsyncCallback callback, object asyncState)
{
return base.Channel.BeginJoin(name, callback, asyncState);
}
public Person[] EndJoin(System.IAsyncResult result)
{
return base.Channel.EndJoin(result);
}
public void Leave()
{
base.Channel.Leave();
}
public void Say(string msg)
{
base.Channel.Say(msg);
}
public void Whisper(string to, string msg)
{
base.Channel.Whisper(to, msg);
}
}
#endregion
OK, so now that I've described the skeleton, boiler plate code that both the Server/Client use to carry out their communications, let's dive in to see how the actual code utilizes the ChatService
(server end) and the client side proxy object also called ChatService
. I think the best way to do this is to list out one of the operations (such as Join
) and follow it from start to finish, from client to server.
One thing to note though, before I start going into this stuff fully, is that I needed to be able to call the client side proxy object methods from several different windows/controls within the attached WPF project. To this end, there is a class called Proxy_Singleton.cs that is really just a singleton wrapper object around all the different operations that need to be carried out for the ChatService
to work correctly. It simply enables the client code to obtain the current singleton object and call its methods without having to worry about instantiating a new proxy object, or keeping track of the correct proxy object.
Join
For the Join
operation, the following sequence is observed
- The user enters a name and assigns an image on the SignInControl.xaml control, where a new
Person
object is created which is accessible via a public property. This control then raises the AddButtonClickEvent
where the container Window1.xaml is listening to this event. The container, Window1.xaml, then hides the SignInControl.xaml control. It grabs the new Person
object that was created by the SignInControl.xaml and calls the Connect
method of the Proxy_Singleton.cs, passing it the new Person
object. - Both Window1.xaml and ChatControl.xaml are then wired up to subscribe to the Proxy_Singleton.cs
ProxyEvent
event and ProxyCallBackEvent
event, such that when the Proxy_Singleton.cs receives a callback message from the server (another chatter), it will fire these events and all listening parties will know what to do with the received data. - The
Proxy_Singleton.cs Connect
method uses an asynchronous connection to call the Join method
on the ChatService
(local proxy). Recall that I told you about asynchronous delegates and how the asynchronous connections were handled by the svcutil.exe tool. - The
Proxy_Singleton.cs Connect
method eventually calls (through WCF magic, tcpBninding
actually) the Join
method of the ChatService
(server). This will return the new List
of chatters to all connected chatters. The server does this by maintaining a multicast delegate that holds a list of delegates of the type public delegate void ChatEventHandler(object sender, ChatEventArgs e);
. There is 1 each per connected chatter. Basically what happens is that whenever one of the IChat
methods on the server is called, the delegate for the client may also called (dependant on message type). Obviously, some messages (such as Whisper) are private. As such, only the particular delegate for the receiver of the message is invoked. Nevertheless, the mechanism is the same. When a message is received by the server, the message type is examined. If it's private, only the particular delegate for the receiver (client) of the message is invoked. Otherwise, all attached client delegates are invoked. When the delegate is invoked by the server, it will use the IChatCallback
interface to make the call back to the client. As the Proxy_Singleton.cs Connect
class implements IChatCallback
, it is capable of receiving and dealing with the callback messages. It will generally make the callback messages available to other objects via internally generated events which, as mentioned earlier, Window1.xaml and ChatControl.xaml have subscribed to.
This probably needs some code snippets to help explain, so let's start with the Proxy_Singleton.cs Connect
code:
public void Connect(Person p)
{
InstanceContext site = new InstanceContext(this);
proxy = new ChatProxy(site);
IAsyncResult iar = proxy.BeginJoin(p,
new AsyncCallback(OnEndJoin), null);
}
private void OnEndJoin(IAsyncResult iar)
{
try
{
Person[] list = proxy.EndJoin(iar);
HandleEndJoin(list);
}
catch (Exception e)
{
MessageBox.Show(e.Message, "Error",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void HandleEndJoin(Person[] list)
{
if (list == null)
{
MessageBox.Show("Error: List is empty", "Error",
MessageBoxButton.OK, MessageBoxImage.Error);
ExitChatSession();
}
else
{
ProxyEventArgs e = new ProxyEventArgs();
e.list = list;
OnProxyEvent(e);
}
}
protected void OnProxyCallBackEvent(ProxyCallBackEventArgs e)
{
if (ProxyCallBackEvent != null)
{
ProxyCallBackEvent(this, e);
}
}
protected void OnProxyEvent(ProxyEventArgs e)
{
if (ProxyEvent != null)
{
ProxyEvent(this, e);
}
}
.....
public void UserEnter(Person person)
{
....
}
So this ends up calling the Join
method in the ChatService
(server), which then uses IChatCallback
to call the client back using the appropriate IChatCallback
method. For example:
- A
Join
method in the ChatService
will result in an IChatCallback UserEnter
method being called. - A
Leave
method in the ChatService
will result in an IChatCallback UserLeave
method being called. - A
Say
method in the ChatService
will result in an IChatCallback Receive
method being called. - A
Whisper
method in the ChatService
will result in an IChatCallback ReceiveWhisper
method being called.
So let's have a look at the Join
/UserEnter
operation at the server end:
public Person[] Join(Person person)
{
bool userAdded = false;
myEventHandler = new ChatEventHandler(MyEventHandler);
lock (syncObj)
{
if (!checkIfPersonExists(person.Name) && person != null)
{
this.person = person;
chatters.Add(person, MyEventHandler);
userAdded = true;
}
}
if (userAdded)
{
callback =
OperationContext.Current.GetCallbackChannel<IChatCallback>();
ChatEventArgs e = new ChatEventArgs();
e.msgType = MessageType.UserEnter;
e.person = this.person;
BroadcastMessage(e);
ChatEvent += myEventHandler;
Person[] list = new Person[chatters.Count];
lock (syncObj)
{
chatters.Keys.CopyTo(list, 0);
}
return list;
}
else
{
return null;
}
}
....
private void MyEventHandler(object sender, ChatEventArgs e)
{
try
{
switch (e.msgType)
{
case MessageType.Receive:
callback.Receive(e.person, e.message);
break;
case MessageType.ReceiveWhisper:
callback.ReceiveWhisper(e.person, e.message);
break;
case MessageType.UserEnter:
callback.UserEnter(e.person);
break;
case MessageType.UserLeave:
callback.UserLeave(e.person);
break;
}
}
catch
{
Leave();
}
}
.....
private void BroadcastMessage(ChatEventArgs e)
{
ChatEventHandler temp = ChatEvent;
if (temp != null)
{
foreach (ChatEventHandler handler in temp.GetInvocationList())
{
handler.BeginInvoke(this, e, new AsyncCallback(EndAsync),
null);
}
}
}
Say
The methodology for the Say
operation is similar to the Join
operation described above, with the following key differences:
- The
Say
operation is NOT called asynchronously - A
Say
method in the ChatService
will result in an IChatCallback Receive
method being called - The
Say
operation is NOT private, so ALL client delegates (at the server) will be invoked
Whisper
The methodology for the Whisper
operation is similar to the Join
operation, with the following key differences:
- The
Whisper
operation is NOT called asynchronously - A
Whisper
method in the ChatService
will result in an IChatCallback ReceiveWhisper
method being called - The
Whisper
operation is private, so only the particular recipients client delegate (at the server) will be invoked
Leave
The methodology for the Leave
operation is similar to the Join
operation, with the following key differences:
- The
Leave
operation is NOT called Asynchronously - A
Leave
method in the ChatService
will result in an IChatCallback UserLeave
method being called - The
Leave
operation is NOT private, so ALL client delegates (at the server) will be invoked
I hope this article shows that all these new Microsoft technologies can actually work together quite well. I have to say, I think this application looks cool. I have been messing around with WPF for a while, nothing serious, but with this one, I tried to have a go. So I hope some of you like it.
I would just like to ask, if you liked the article, please vote for it. Also, leave some comments, as it lets me know if the article was at the right level or not, and whether it contained what people need to know.
I have enjoyed writing this WPF/WCF article and I have now become hopelessly addicted to .NET 3.0/3.5. So if you want to keep a work/life balance, I would suggest you stay far away from .NET 3.0/3.5 if at all possible, because once it's got you in its little tentacles, there really is no escape. "Resistance Is Futile. You Will Be Assimilated."
That said, once you get your head around the vast learning curve that is .NET 3.0, it's really much better IMHO than previous .NET 2.0 offerings.
- v1.0 23/07/07: Initial issue
- 22/7/11: Updated download demo zip
I would personally like to thank Josh Smith for his help advice about threading in WPF and his unstoppable WPFing that seems to be popping up everywhere. He is a great source of knowledge for all things WPF, so spend some time visiting his Blog he has lots of good resources there.