| Comments

This is part 4 in a series on getting started with Silverlight.  To view the index to the series click hereYou can download the completed project files for this sample application in C# or Visual Basic.

In the previous step 3 we did a lot of work to get back our data from a public web service and display it in a control.  The DataGrid control we used, however, isn’t really the UI we’re looking for so let’s define what we want.  To do this we’re going to use an ItemsControl and a DataTemplate.  This will introduce us to XAML binding syntax and how to leverage more powerful data binding information.

Starting the UI over – delete the DataGrid

Well, after all that work, let’s delete the DataGrid and just the DataGrid.  We won’t be needing the assembly reference or the xmlns:data values anymore as well, so go ahead and remove them.

Replace the DataGrid with an ItemsControl like this:

   1: <ItemsControl x:Name="SearchResults" Margin="0,8,0,0" Grid.Row="1" />

Now here is where Blend is going to be helpful for us again.  Go into Blend and we’re going to modify the ItemTemplate for the ItemsControl.  ItemsControl is just essentially a rendering control that does what we tell it to.  If we do nothing but change the DataGrid to an ItemsControl and run our application this is what we’ll get:

ItemsControl rendering with no template

The ItemsControl has no idea how we want to display the data, so we have to tell it how in a template…back to Blend.  The general concept we’re going to go for is this (repeated of course):

ItemsControl template mockup

where the box is the avatar of the user posting the message.  Using our knowledge of layout from the previous steps we can create the template easily.  In Blend, locate the SearchResults object in the tree and right click to edit the ItemsTemplate (under the Generated Templates section):

Edit Generated Items menu option

we now have an empty template we can put stuff in.  I called mine SearchResultsTemplate in the dialog that came up.  Now we are in layout editing mode and can drag/move/etc items in our layout.  I created a Grid-based layout and here’s my resulting XAML for the template:

   1: <DataTemplate x:Key="SearchResultsTemplate">
   2:     <Grid Margin="4,0,4,8" d:DesignWidth="446" d:DesignHeight="68">
   3:         <Grid.ColumnDefinitions>
   4:             <ColumnDefinition Width="Auto" />
   5:             <ColumnDefinition Width="*" />
   6:         </Grid.ColumnDefinitions>
   7:         <Border VerticalAlignment="Top" Margin="8" Padding="2" Background="White">
   8:             <Image Width="40" Height="40" />
   9:         </Border>
  10:  
  11:         <StackPanel Grid.Column="1" VerticalAlignment="Top" Margin="0,4,0,0">
  12:             <TextBlock x:Name="AuthorName" FontWeight="Bold" />
  13:             <Grid Margin="0,6,0,0">
  14:                 <Grid.RowDefinitions>
  15:                     <RowDefinition Height="Auto" />
  16:                     <RowDefinition Height="2" />
  17:                     <RowDefinition Height="Auto" />
  18:                 </Grid.RowDefinitions>
  19:                 <TextBlock x:Name="TweetMessage" TextWrapping="Wrap" />
  20:                 <TextBlock x:Name="PublishDateLabel" Grid.Row="2"  />
  21:             </Grid>
  22:         </StackPanel>
  23:     </Grid>
  24: </DataTemplate>

I’m also putting the ItemsControl itself into a ScrollViewer since the ItemsControl doesn’t natively provide a scrolling view:

   1: <ScrollViewer Grid.Row="2" HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto" BorderThickness="1">
   2:         <ItemsControl x:Name="SearchResults" Margin="0,8,0,0" Grid.Row="1" ItemTemplate="{StaticResource SearchResultsTemplate}" />
   3:     </ScrollViewer>

Now all we have is a template, but we have to tell that template what to do with the data it will be receiving.

The XAML binding syntax

Here’s where our binding syntax is going to come in.  You see, ItemsControl is getting data to it (remember we haven’t changed our code so the SearchResults.ItemsSource is still being set to our PagedCollectionView.  To map our model elements to our template we need to use Binding.  The basic XAML binding syntax is:

{Binding Path=<some-data-path>, Mode=<binding mode>}

There are more advanced features you could get into, but this is the basic and we’ll start here.  For instance, to bind our Image element in our template to our Avatar from TwitterSearchResult model, it will look like this:

   1: <Image Width="40" Height="40" Source="{Binding Path=Avatar, Mode=OneWay}" />

And to bind the Author to the AuthorName element like this:

   1: <TextBlock x:Name="AuthorName" FontWeight="Bold" Text="{Binding Path=Author, Mode=OneWay}" />

In both of these we are using OneWay syntax because we don’t need to have it be TwoWay as we aren’t changing data back.  For the PublishDate, we want to provide some explicit formatting of the data.  We can do this through Value Converters.

Building a Value Converter

Value converters are classes that implement IValueConverter, which provides a Convert and ConvertBack methods.  For our PublishDate we’re going to basically allow explicit formatting of the DateTime object.  We’ll create a DateTimeConverter.cs class in a folder in our project called Converters.  The class looks like this:

   1: using System;
   2: using System.Threading;
   3: using System.Windows.Data;
   4:  
   5: namespace TwitterSearchMonitor.Converters
   6: {
   7:     /*
   8:      * Use this converter for formatting dates in XAML databinding
   9:      * Example:
  10:      *  Text="{Binding Path=PublishDate, Converter={StaticResource DateTimeFormatter}, ConverterParameter=MMM yy}" />
  11:      * 
  12:      * */
  13:     public class DateTimeConverter : IValueConverter
  14:     {
  15:         #region IValueConverter Members
  16:  
  17:         public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
  18:         {
  19:             DateTime? bindingDate = value as DateTime?;
  20:  
  21:             if (culture == null)
  22:             {
  23:                 culture = Thread.CurrentThread.CurrentUICulture;
  24:             }
  25:  
  26:             if (bindingDate != null)
  27:             {
  28:                 string dateTimeFormat = parameter as string;
  29:                 return bindingDate.Value.ToString(dateTimeFormat, culture);
  30:             }
  31:  
  32:             return string.Empty;
  33:         }
  34:  
  35:         public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
  36:         {
  37:             throw new NotImplementedException();
  38:         }
  39:  
  40:         #endregion
  41:     }
  42: }

Now to use this we'll go back to our XAML page that will be using this (Search.xaml) and add an xmlns declaration and a resource.  The xmlns we’ll use looks like this:

   1: xmlns:converters="clr-namespace:TwitterSearchMonitor.Converters"

and then in the Resources section of the XAML (where the other template is defined) we’ll add a resource that points to the converter:

   1: <navigation:Page.Resources>
   2:         <converters:DateTimeConverter x:Key="DateTimeFormatter" />
   3: ...

With these two things in place we can use our converter on our PublishDateLabel element like this:

   1: <TextBlock x:Name="PublishDateLabel" Text="{Binding Path=PublishDate, 
   2:         Converter={StaticResource DateTimeFormatter},
   3:         ConverterParameter=dd-MMM-yyyy hh:mm tt}" Grid.Row="2"  />

This tells XAML that it should run the IValueConverter to get the rendered output.  Our result is the explicit formatting of data that we want.  The result of all this additional syntax for binding now shows the rendering in our desired mockup:

Rendered ItemsControl data template

(yes I know that ‘twitpic’ as a search shows some interesting results…but you can count on it to have fast refreshing data as a search term!)

Great!  That wasn’t that difficult, was it?  This binding syntax will be essential to building applications for you.

Storing some settings and configuration data

One of the things that would be helpful for our application is to store the last tweet ID so that the next time the application is run, we can start where we left off without starting over.  Additionally it would be cool to save the search term history so that we can view it in our History navigation point later.

To accomplish this, we’ll be using Isolated Storage available in Silverlight.  Isolated Storage enables a low-trust user-specific location for storing simple data.  For some more information on Isolated Storage:

To do this I’m going to add a Helper class to our Model folder.  This helper class will assist us in saving/reading data from our isolated storage location.  The basics of IsolatedStorage are that you create a file to which you can read data from or write data to if you want.  In our use we’ll use IsolatedStorageSettings for saving simple name/value pair data (search term/last ID).  Here’s the contents of the Helper.cs class:

   1: using System.IO.IsolatedStorage;
   2:  
   3: namespace TwitterSearchMonitor.Model
   4: {
   5:     public class Helper
   6:     {
   7:         internal static string GetLatestTweetId(string searchTerm)
   8:         {
   9:             if (IsolatedStorageSettings.ApplicationSettings.Contains(searchTerm))
  10:             {
  11:                 return IsolatedStorageSettings.ApplicationSettings[searchTerm].ToString();
  12:             }
  13:             else
  14:             {
  15:                 return "0";
  16:             }
  17:         }
  18:  
  19:         internal static void SaveLatestTweetId(string searchTerm, string latestId)
  20:         {
  21:             if (IsolatedStorageSettings.ApplicationSettings.Contains(searchTerm))
  22:             {
  23:                 IsolatedStorageSettings.ApplicationSettings[searchTerm] = latestId;
  24:             }
  25:             else
  26:             {
  27:                 IsolatedStorageSettings.ApplicationSettings.Add(searchTerm, latestId);
  28:             }
  29:         }
  30:     }
  31: }

Now in our Search.xaml.cs we’ll add the following in SearchForTweetsEx after the activity indicator is set:

   1: _lastId = Helper.GetLatestTweetId(SearchTerm.Text); // get the latest ID from settings
   2:  
   3: Helper.SaveLatestTweetId(SearchTerm.Text, _lastId); // saving for history even if a result isn't found

and then in the OnReadCompleted after we close the XmlReader we’ll add this:

   1: Helper.SaveLatestTweetId(SearchTerm.Text, _lastId); //saving last tweet id

And that now saves the search terms used as well as the last ID found if a result was found.

Summary

In this step we’ve set up a data template for a control, used some simple data binding using the XAML declarative syntax, added a value converter to format our view of information and save settings information to a local storage mechanism.

We’ve got basically our application working, so let’s start adding some interesting polish to the UI.

Let’s move on to part 5 where we add some new controls to enhance the experience using the data we just stored in this step.


This work is licensed under a Creative Commons Attribution By license.

| Comments

Yesterday a minor update to the Silverlight 2 runtime was released.  You may see terms of “GDR 1” floating around.  That’s pretty much an internal term at Microsoft referred to as “general distribution release” – yeah, I know we have a lot of random terms.  Think of it just as an incremental update.  If you are curious for Silverlight’s entire release history, you can view that here.

So what’s in it?  Well, nothing earth shattering if you were expecting feature updates.  There were some needed fixes based on customer feedback that we included in this update (which is version 2.0.40115.0).  Basically I would bucket it into a simple list for you:

  • Various accessibility fixes relating to UIAutomation
  • Fixes for certain anti-virus vendors scanning algorithms
  • Much needed fix for OSX platform when users modify their font locations (i.e., people with font management tools usually)
  • Bug in IsolatedStorage quota increasing when the user’s display language is set to one that Silverlight has not been localized to

That’s about it…no new controls, nothing suddenly going to make you amazed and shocked.  But it was a needed release to fix these issues. 

So what should you do?  Depends. 

If you are a consumer…

You really shouldn’t need to do anything unless you are specifically experiencing one of the issues above.  Web developers using Silverlight and needing their user’s to have this update will update their site and let you know.  There isn’t a huge need to rush out as an end-user and download the update.  Besides, if you have it configured to automatically update, then in due time you’ll get the update through that mechanism and won’t have to do anything.  If you absolutely want to be on top of things, then feel free to grab the latest updated runtime by visiting the download link.

If you are a developer…

If you are directly affected by the changes above then you should update your application.  There are really two things you need to do:

For the latter note this is a simple change.  Update your “MinRuntimeVersion” attribute in your hosting page/content to ensure that end users have the latest runtime version for your application.  Again, you should only really need/want to do this proactively if you (or your users) are directly affected by the updates in this release!  Here’s how you’d do it.

NOTE: Even though you update the developer runtime the Visual Studio project templates are not updated to set the default runtime version to the updated version so each new Silverlight project using those templates will still reference minRuntimeVersion=”2.0.31005.0” – if you want to change that you can modify the template.

UPDATE: How to modify the default value of the template

I’ve received some errors myself after changing this, so while it sounded like a good idea :-), I can’t recommend altering this reg value to the updated version – if I find out why, I’ll post here.

I got some questions about exactly how you would get the default web templates to change the generated test pages to emit the updated version number.  Since these are pages in a web project and not the Silverlight project, it isn’t easily found.  In fact, that value gets pulled from a registry setting.  The setting is at (removing the Wow6432Node if you are not on 64-bit Windows):

   1: [HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Microsoft\Microsoft SDKs\Silverlight\v2.0\ReferenceAssemblies]
   2: "SLRuntimeInstallVersion"="2.0.40115.0"

and you need to change the value to match what you want that to be.  Again, this is not a required step at all.

As a reminder, messing with your registry can be dangerous if you have no idea what you are doing.  Backup your reg keys, don’t blame me if something goes wrong :-).

If you are using the ASP.NET Silverlight control you’d modify the MinimumVersion and AutoUpgrade attributes:

   1: <asp:Silverlight ID="Xaml1" AutoUpgrade="true" runat="server" 
   2:                 Source="~/ClientBin/SilverlightApplication3.xap" 
   3:                 MinimumVersion="2.0.40115.0" Width="100%" Height="100%" />

If you are using the <object> tag instantiation you’d modify the minRuntimeVersion and autoUpgrade attributes in the <object> tag for your Silverlight application:

   1: <param name="minRuntimeVersion" value="2.0.40115.0" />
   2: <param name="autoUpgrade" value="true" />

If you are using Silverlight.js to create the object you’d modify the version attribute when passing it in to the properties parameter of the createObject function (sample):

   1: Silverlight.createObject(
   2:             "ClientBin/SilverlightApplication1.xap",  // source
   3:             silverlightControlHost,  // parent element
   4:             "slPlugin",  // id for generated object element
   5:             {
   6:                 width: "100%", height: "100%", background: "white", 
   7:                 version:"2.0.40115.0"
   8:             },
   9:             { onError: onSLError, onLoad: onSLLoad },
  10:             "param1=value1,param2=value2", 
  11:             "context"    // context helper for onLoad handler.
  12:         );

That’s it!  Hope this helps.  The team is diligently working on Silverlight 3!

| Comments

Someone posed this question (“Can you use IsolatedStorage in Silverlight as a more reliable browser caching technique?”) to me and I answered with my usual optimistic “in theory, yes” answer.  Of course I had never tried it which is horrible to answer that to someone without trying it.  In working on creating some Silverlight business application samples, I figured I should probably look at this scenario to see if a) it would work and b) it makes sense.  I’ll at least try to answer “a” here.

The Setup

Let’s look at the setup.  The goal here is to have a lean startup application and only request additional components/controls/applications when needed, thus producing the best startup experience for the end user.  I decided to use an existing simple sample I already had which does dynamic XAP loading for an application.  Basically the application loads a simple interface of a search box and button.  Since the user hasn’t interacted with data yet, we didn’t want to add the payload of DataGrid into the initial application.  Once the button is clicked the application is requested, loaded into memory and displayed.  The relevant code from that sample is shown here:

   1: private void Button_Click(object sender, RoutedEventArgs e)
   2: {
   3:     panel1.Children.Clear();
   4:  
   5:     WebClient c = new WebClient();
   6:     c.OpenReadCompleted += new OpenReadCompletedEventHandler(c_OpenReadCompleted);
   7:     c.OpenReadAsync(new Uri("DynamicXapDataGrid_CS.xap", UriKind.Relative));
   8: }
   9:  
  10: void c_OpenReadCompleted(object sender, OpenReadCompletedEventArgs e)
  11: {
  12:     string appManifest = new StreamReader(Application.GetResourceStream(new StreamResourceInfo(e.Result, null), new Uri("AppManifest.xaml", UriKind.Relative)).Stream).ReadToEnd();
  13:  
  14:     Deployment deploy = XamlReader.Load(appManifest) as Deployment;
  15:  
  16:     Assembly asm = null;
  17:  
  18:     foreach (AssemblyPart asmPart in deploy.Parts)
  19:     {
  20:         string source = asmPart.Source;
  21:         StreamResourceInfo streamInfo = Application.GetResourceStream(new StreamResourceInfo(e.Result, "application/binary"), new Uri(source, UriKind.Relative));
  22:  
  23:         if (source == "DynamicXapDataGrid_CS.dll")
  24:         {
  25:             asm = asmPart.Load(streamInfo.Stream);
  26:         }
  27:         else
  28:         {
  29:             asmPart.Load(streamInfo.Stream);
  30:         }
  31:     }
  32:  
  33:     panel1.DataContext = FakeData.GetCustomers(LastNameSearch.Text);
  34:  
  35:     UIElement myData = asm.CreateInstance("DynamicXapDataGrid_CS.Page") as UIElement;
  36:  
  37:     panel1.Children.Add(myData);
  38:     panel1.UpdateLayout();
  39:  
  40: }

You can see that in line 7 above we are using OpenRead to request our satellite XAP which contains our control/logic for our DataGrid, and then starting in line 18 we look for the relevant assembly to load and instantiate (line 35) and put into the visual tree (line 37).

Browser Caching The Request

If we would execute the app (clicking the button) several times, we’d expect that the first time the browser would request the satellite XAP and deliver it.  We would also generally expect the browser to cache that request for later.  So that if we click the button again, it would retrieve from cache and operate faster.

But what about corporate policies and other types of things that may prevent the browser from caching that request?  What if we want a different mechanism for caching our object requests and satellite assemblies?

An alternate method, use IsolatedStorage

So how can we refactor the above to use Silverlight’s IsolatedStorage area as a cache for our satellite assemblies?  let’s take a look.  First if you follow similar techniques as you might in ASP.NET for caching, you’d first check the cache before you load something.  If it isn’t there, you’d load it, add it to the cache and then use it for later as well.  Let’s do that here.

Instead of making the OpenRead request on the button click, we should first check to see if the satellite XAP has been loaded into IsoStore:

   1: private void Button_Click(object sender, RoutedEventArgs e)
   2: {
   3:     panel1.Children.Clear();
   4:  
   5:     using (var store = System.IO.IsolatedStorage.IsolatedStorageFile.GetUserStoreForApplication())
   6:     {
   7:         if (!store.FileExists("DynamicXapDataGrid_CS.xap"))
   8:         {
   9:             WebClient c = new WebClient();
  10:             c.OpenReadCompleted += new OpenReadCompletedEventHandler(c_OpenReadCompleted);
  11:             c.OpenReadAsync(new Uri("DynamicXapDataGrid_CS.xap", UriKind.Relative));
  12:         }
  13:         else
  14:         {
  15:             LoadData();
  16:         }
  17:     }
  18: }

Line 5 shows us initating the IsolatedStorage area.  (For more information on IsolatedStorage usage, see this video.)  We first look to see if the file exists in the store.  If it is not in there, then we use our OpenRead functionality to start the request.  If the file is there, then we call our refactored LoadData() function, which we’ll look at in a moment.  The completed event for our OpenReadAsync call looks like this:

   1: void c_OpenReadCompleted(object sender, OpenReadCompletedEventArgs e)
   2: {
   3:     using (var store = System.IO.IsolatedStorage.IsolatedStorageFile.GetUserStoreForApplication())
   4:     {
   5:         System.IO.IsolatedStorage.IsolatedStorageFileStream fileStream;
   6:  
   7:         // create the file
   8:         fileStream = store.CreateFile("DynamicXapDataGrid_CS.xap");
   9:  
  10:         // write out the stream to isostore
  11:         WriteStream(e.Result, fileStream);
  12:  
  13:         fileStream.Close();
  14:     }
  15:     LoadData();
  16: }
  17:  
  18: private void WriteStream(Stream stream, System.IO.IsolatedStorage.IsolatedStorageFileStream fileStream)
  19: {
  20:     byte[] buffer = new byte[4096];
  21:     int bytesRead;
  22:  
  23:     while ((bytesRead = stream.Read(buffer, 0, buffer.Length)) != 0)
  24:     {
  25:         fileStream.Write(buffer, 0, bytesRead);
  26:     }
  27: }

What we see here is that in our completed event is where we actually create the file and put it in our IsoStore location using a little helper method to write the stream data out.  Now we have a satellite assembly in our IsolatedStorage for our application.

You can see that in the completed event we also call LoadData().  This is really the same core code we previously had that we refactored into a common method:

   1: private void LoadData()
   2: {
   3:     using (var store = System.IO.IsolatedStorage.IsolatedStorageFile.GetUserStoreForApplication())
   4:     {
   5:         IsolatedStorageFileStream fileStream = store.OpenFile("DynamicXapDataGrid_CS.xap", FileMode.Open, FileAccess.Read);
   6:  
   7:         #region Original Code
   8:         StreamResourceInfo sri = new StreamResourceInfo(fileStream, "application/binary");
   9:  
  10:         Stream manifestStream = Application.GetResourceStream(sri, new Uri("AppManifest.xaml", UriKind.Relative)).Stream;
  11:         string appManifest = new StreamReader(manifestStream).ReadToEnd();
  12:  
  13:         Deployment deploy = XamlReader.Load(appManifest) as Deployment;
  14:  
  15:         Assembly asm = null;
  16:         
  17:         foreach (AssemblyPart asmPart in deploy.Parts)
  18:         {
  19:             string source = asmPart.Source;
  20:  
  21:             // next line is a hack for Beta 2 and is not needed in Silverlight 2 release
  22:             fileStream = store.OpenFile("DynamicXapDataGrid_CS.xap", FileMode.Open, FileAccess.Read);
  23:  
  24:             StreamResourceInfo streamInfo = Application.GetResourceStream(new StreamResourceInfo(fileStream, "application/binary"), new Uri(source, UriKind.Relative));
  25:  
  26:             if (source == "DynamicXapDataGrid_CS.dll")
  27:             {
  28:                 asm = asmPart.Load(streamInfo.Stream);
  29:             }
  30:             else
  31:             {
  32:                 asmPart.Load(streamInfo.Stream);
  33:             }
  34:         }
  35:  
  36:         panel1.DataContext = FakeData.GetCustomers(LastNameSearch.Text);
  37:  
  38:         UIElement myData = asm.CreateInstance("DynamicXapDataGrid_CS.Page") as UIElement;
  39:  
  40:         panel1.Children.Add(myData);
  41:         panel1.UpdateLayout();
  42:         #endregion
  43:  
  44:     }
  45: }

You’ll note that we pull the file out of IsoStore and do our same loading from there.  Please note the comment for line 22.  This is a bug in Silverlight 2 Beta 2 where the Stream is closed too early and thus you’d get an exception on line 24 because the Stream was already closed in line 10.  The code above is a hack for sure (and inefficient).  We could have subclassed Stream and modified the Close functionality, but since I know it works in the release code I wasn’t going to bother you with some unnecessary huge workaround at this point, but line 22 can be removed at release time.

The Results

So putting it all together let’s look at how the requests look.  Let’s wipe out our IsolatedStorage so that nothing is in there:

Now when we click on our button in our application, we can see that since there is nothing in our IsoStore, the HTTP traffic goes through:

and we now have something in our IsoStore as evident by the preferences dialog:

or more specifically by spelunking to the actual location for IsolatedStorage, we can see our XAP located there:

Sweet.  Now future button clicks show no more HTTP traffic because it is pulling it out of IsolatedStorage.  If we clear our browser cache, it is still there.  If we delete our cookies, the XAP is still there.  Now when our app users return, they’ll have the satellite assemblies/XAPs we need for our apps already on their machine.

Caution and Thoughts

Okay, while this “works” there are definitely some cautionary decisions you’d have to make from an application architecture point of view.  Here are some thoughts…

    • What about versioning of your satellite assemblies and controls?  If you use the exact method here, any update would never be retrieved.  Some mechanism of adding data to the IsoStore information should be considered so you know your users have what your application expects.
    • What about the size of the XAP and the available storage space in IsoStore?  You should know the sizes of things you are going to place into IsoStore, and in the code above before placing anything in there, you should look at the available space and request a quota increase if needed.  Information on how this can be done is explained here.
    • What about new browser functionality like InPrivate and In Cognito windows from browsers?  Since both of these functions are in browsers that are beta, it is hard to say how their final implementations will effect the use of IsolatedStorage, but it is something to consider and test.

At any rate, an interesting pattern that you may want to consider for your application if it makes sense.  Is it more reliable than the browser cache?  I don’t think you can universally answer that because almost all client configurations for stuff like that seem to vary…but it is an alternative!  Here’s the code for the completed solution I modified.

| Comments

Some new videos just got posted to the Silverlight community site.  Topics included:

As always, feedback and ideas are welcome.  Ben’s been leaving some great comments here on suggested topics and I’d love to see more.  I’ll be starting a new ‘series’ soon…more to come on that in a week’s time.

As a reminder, these videos are meant to help jump start some learning.  We try to keep them intermediate initially so they aren’t incredibly simple and aren’t incredibly difficult.  If we are missing the mark, I expect that you’ll leave comments on those videos that do so!

Related topics: