Sunday, September 19, 2010

Hosting HTML in Silverlight (not Out of Browser)

Some Silverlight projects may require that you render HTML content - perhaps a non-Silverlight control or web page. Unfortunately unless the project runs Out of Browser (OOB) the WebBrowser control isn't an option. What can you do?

While there is no easy solution to truly embed HTML inside of the Silverlight rendering engine, it is possible to render the HTML using the browser into a DIV element that is overlaid on your Silverlight application. In fact, because the rendering engine is powerful enough to provide you with all of the sizing events and triggers you need, you can create a very convincing experience displaying HTML in your application - even from the browser.

To illustrate this in action, we'll build a sort of "Silverlight browser" that allows you to navigate to any web page. In reality, we'll load the URL into an IFRAME that lives inside a DIV. What will be important to note is that the DIV automatically moves and resizes when you resize the application, as if it were a part the application itself!

Silverlight Browser

In fact, before we jump into the code, why not click here to check it out yourself? Note that FireFox and IE handle this fine. Chrome creates an infinite loop by placing a scrollbar in the iframe, which resizes it, which then triggers an app resize and ... you get the point. Working with HTML this way in Chrome is fine if you are just injecting the HTML, but using iframes requires some tweaks to avoid scrollbars and set margins for Chrome to behave, and quite frankly I didn't have time to do all of the tweaking for his example.

First, we'll set up the DIV. In order for this to work, we also need to set windowless to true for our Silverlight application, so that we can safely overlay the HTML content. The host page now looks like this:

<form id="form1" runat="server" style="height:100%">
<div id="silverlightControlHost">
    <div id="htmlHost" style="visibility: hidden; position: absolute;"></div>
    <object data="data:application/x-silverlight-2," type="application/x-silverlight-2" width="100%" height="100%">
  <param name="source" value="ClientBin/HtmlExample.xap"/>
  <param name="onError" value="onSilverlightError" />
  <param name="background" value="white" />
              <param name="windowless" value="true" />
  <param name="minRuntimeVersion" value="4.0.50826.0" />
  <param name="autoUpgrade" value="true" />
  <a href="http://go.microsoft.com/fwlink/?LinkID=149156&v=4.0.50826.0" style="text-decoration:none">
    <img src="http://go.microsoft.com/fwlink/?LinkId=161376" alt="Get Microsoft Silverlight" style="border-style:none"/>
  </a>
 </object><iframe id="_sl_historyFrame" style="visibility:hidden;height:0px;width:0px;border:0px"></iframe></div>
</form>

The DIV we'll use is called htmlHost. Notice that we position it absolutely and leave it hidden by default.

Next, we'll create a control for the HTML content. The control will do a few things:

  • It will accept a URL to render
  • It will interact with the DIV to inject an IFRAME that points to the URL
  • It will automatically synchronize the size and position of the DIV to a Grid inside of the Silverlight application

We'll create a control called HtmlHost. In the Xaml, we only define one thing: the Grid we'll use to synchronize with the DIV. Take a look:

<Grid x:Name="LayoutRoot" HorizontalAlignment="Stretch" MinWidth="100" MinHeight="100" VerticalAlignment="Stretch" Background="Aquamarine">
    </Grid>

I gave it a background that contrasts with the rest of the application so you can visually see how the DIV moves as you resize and move the application.

In the code behind, I defined some constants for the attributes to manipulate on the DIV and the markup for the IFRAME. The control has two dependency properties: a HostDiv that points to the id of the DIV it will synchronize with, and a Url to load into the DIV.

namespace HtmlExample
{
    public partial class HtmlHost
    {
        private const string IFRAME =
            @"<iframe height=""100%"" width=""100%"" marginheight=""1"" marginwidth=""1"" src=""{0}""></iframe>";

        private const string ATTR_INNER_HTML = "innerHTML";
        private const string ATTR_LEFT = "left";
        private const string ATTR_TOP = "top";
        private const string ATTR_WIDTH = "width";
        private const string ATTR_HEIGHT = "height";
        private const string ATTR_VISIBILITY = "visibility";
        private const string VISIBLE = "visible";
        private const string HIDDEN = "hidden";
        private const string PX = "{0}px";
        private HtmlElement _div;

        private double _width, _height;

        public static DependencyProperty HostDivProperty = DependencyProperty.Register(
            "HostDiv",
            typeof (string),
            typeof (HtmlHost),
            null);

        public string HostDiv
        {
            get { return GetValue(HostDivProperty).ToString(); }

            set
            {                
                SetValue(HostDivProperty, value);
                if (!DesignerProperties.IsInDesignTool)
                {
                    _div = HtmlPage.Document.GetElementById(value);
                }
            }
        }

        public static DependencyProperty UrlProperty = DependencyProperty.Register(
            "Url",
            typeof (string),
            typeof (HtmlHost),
            null);

        public string Url
        {
            get { return GetValue(UrlProperty).ToString(); }

            set
            {                
                SetValue(UrlProperty, value);
                if (!DesignerProperties.IsInDesignTool)
                {
                    _div.SetProperty(ATTR_INNER_HTML, string.Format(IFRAME, value));
                }
            }
        }

Whenever the host DIV is set, the control grabs an HtmlElement reference to the DIV. Whenever the URL property is set, the DIV is injected with the IFRAME code to point to the URL.

When the control is constructed, we'll wait for it to load, record the current width and height, and then set up the synchronization:

public HtmlHost()
{
    InitializeComponent();
    if (DesignerProperties.IsInDesignTool)
    {
        return;
    }
    Loaded += (o, e) =>
                    {
                        _width = Width;
                        _height = Height;
                        if (_div != null)
                        {
                            Show();
                        }
                    };
}

The synchronization is setup up in the Show method. Here, we set the DIV to be visible. We hook into two events. Whenever the entire application is resized, we'll need to compute a new offset for the DIV. Whenever the grid is resized, we'll need to compute a new height and width. The set up for these hooks looks like this:

public void Show()
{
    _div.RemoveStyleAttribute(ATTR_VISIBILITY);
    _div.SetStyleAttribute(ATTR_VISIBILITY, VISIBLE);
    Application.Current.Host.Content.Resized += Content_Resized;
    LayoutRoot.SizeChanged += LayoutRoot_SizeChanged;
    _Resize();
}

private void LayoutRoot_SizeChanged(object sender, SizeChangedEventArgs e)
{
    _width = e.NewSize.Width;
    _height = e.NewSize.Height;
}

private void Content_Resized(object sender, System.EventArgs e)
{
    _Resize();
}

Notice we're banking on the fact that the only way the grid can be resized is when the application is, therefore we just record the height and width for the _Resize method to use.

The resize is where the magic happens that allows us to place the DIV so it covers our Grid exactly. Take a look:

private void _Resize()
{
    var gt = LayoutRoot.TransformToVisual(Application.Current.RootVisual);
    var offset = gt.Transform(new Point(0, 0));
    _div.RemoveStyleAttribute(ATTR_LEFT);
    _div.RemoveStyleAttribute(ATTR_TOP);
    _div.RemoveStyleAttribute(ATTR_WIDTH);
    _div.RemoveStyleAttribute(ATTR_HEIGHT);

    _div.SetStyleAttribute(ATTR_LEFT, string.Format(PX, offset.X));
    _div.SetStyleAttribute(ATTR_TOP, string.Format(PX, offset.Y));
    _div.SetStyleAttribute(ATTR_WIDTH, string.Format(PX, _width));
    _div.SetStyleAttribute(ATTR_HEIGHT,
                            string.Format(PX, _height));
}

The TransformToVisual gives us a reference to know where our Grid is relative to the entire Silverlight application, which was configured to take up the entire browser window. Doing this, we can compute the offset of the grid from the left and top edge of the application. Then, we can simply take this same offset in pixels and apply it to the top and left style attributes of the DIV. We then apply the height and width, and we're done!

To use the control, I created a simple form that accepts a Url and on the button click will update the control. I used gradients in the background to show how it overlays the Silverlight application. Dropping in the control with a default Url looks like this:

<HtmlExample:HtmlHost Grid.Row="1" x:Name="HtmlHostCtrl" 
Margin="40" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
HostDiv="htmlHost" Url="http://www.jeremylikness.com/"/>

Notice I passed the id of the DIV and a starting Url.

You can see the control in action here (and again, if you are in Chrome, you'll see a LOT of action):

And finally, you can grab the source code by clicking here.

Enjoy!

Jeremy Likness