Supercharging Xamarin Forms with Custom Renderers, Part 3

One of the limitations of Xamarin Forms that frequently pains developers is the fact that the Label control supports text wrapping and text truncation, but it doesn’t support both at the same time. Specifically, Label’s LineBreakMode property can be set to LineBreakMode.WrapText to wrap text, or it can be set to LineBreakMode.TailTruncation to replace text at the end of a string with an ellipsis (…). But it can’t be set to both. Imagine you’re writing a Twitter client around a ListView control, and you want to show two or three lines of text for each tweet, with an ellipsis filling in for any text that won’t fit into the space provided. Sounds simple, but Xamarin Forms doesn’t offer a handy solution.

I ran into this problem when porting Contoso Cookbook to Xamarin Forms. On the app’s main page, I wanted to include a few lines of descriptive text under each recipe title. But the fact that Label doesn’t support wrapped, truncated text presented a problem. Rather than solve the problem then and risk taking away from the focus of the article, I decided to put it off until I published a series on custom renderers. This is Part 3 in that series, and it’s time to tackle wrapped, truncated text. Here’s a revised version of Contoso Cookbook that displays descriptive text under each recipe title:

image

And here’s how custom renderers are used to generate wrapped and truncated text – and another example of why custom renderers play an important role in many Xamarin Forms apps.

The WrappedTruncatedLabel Control

The first step in supporting wrapped, truncated text in Contoso Cookbook was to derive a class named WrappedTruncatedLabel from Label:

public class WrappedTruncatedLabel : Label
{
}

As I did in the previous article in this series, where I built a custom button control by deriving from Xamarin.Forms.Button, I elected not to add any properties to the derived class. (When deriving from Xamarin Forms control classes, it’s common to add bindable properties to the derived classes to expose functionality not present in the base classes. I didn’t need that here, but rest assured that in a future article, I’ll demonstrate how to enhance derived classes with properties of their own.) I then modified the data template for the ListView declared in MainPage.xaml to include a 2-row Grid to the right of the recipe image, with the first row containing a Label control bound to the recipe title, and the second row containing a WrappedTruncatedLabel bound to the recipe description. I also sprinkled in a few OnPlatforms to get the look I wanted on all three platforms:

<ListView ItemsSource="{Binding Recipes}" ItemTapped="OnItemTapped">
  <ListView.RowHeight>
    <OnPlatform x:TypeArguments="x:Int32" iOS="128" Android="136" WinPhone="144" />
  </ListView.RowHeight>
  <ListView.ItemTemplate>
    <DataTemplate>
      <ViewCell>
        <Grid Padding="8">
          <Grid.ColumnDefinitions>
            <ColumnDefinition>
              <ColumnDefinition.Width>
                <OnPlatform x:TypeArguments="GridLength" iOS="120" Android="144" WinPhone="144" />
              </ColumnDefinition.Width>
            </ColumnDefinition>
            <ColumnDefinition Width="*" />
          </Grid.ColumnDefinitions>
          <Image Source="{Binding ImagePath, Converter={StaticResource LocalImagePathConverter}}" />
          <Grid Grid.Column="1">
            <Grid.RowDefinitions>
              <RowDefinition Height="Auto" />
              <RowDefinition Height="*" />
            </Grid.RowDefinitions>
            <Grid.Padding>
              <OnPlatform x:TypeArguments="Thickness" iOS="4,8,0,8" Android="8,2,0,4" WinPhone="12,-2,0,-4" />
            </Grid.Padding>
            <Grid.RowSpacing>
              <OnPlatform x:TypeArguments="x:Double" iOS="0" Android="4" WinPhone="4" />
            </Grid.RowSpacing>
            <Label Text="{Binding Subtitle}" FontSize="Large" LineBreakMode="WordWrap" />
            <local:WrappedTruncatedLabel Text="{Binding Description}" FontSize="Small" Grid.Row="1" />
          </Grid>
        </Grid>
      </ViewCell>
    </DataTemplate>
  </ListView.ItemTemplate>
</ListView>

Notice how I sized the two Grid rows: the first is auto-sized, which allocates the amount of space needed to display the recipe title, while the second is star-sized (*), which says “allocate the remaining space in the Grid to this row.” It now falls to the custom renderers accompanying WrappedTruncatedLabel to display wrapped, truncated text in that space.

Wrapping and Truncating Text in iOS

iOS programmers employ a little trick when they want to display wrapped, truncated text in a UILabel control: they set the UILabel’s LineBreakMode property to LineBreakMode.TailTruncation, and the UILabel’s Lines property to 0. Knowing that, it’s a simple matter to write a renderer for WrappedTruncatedLabel that produces wrapped, truncated text:

[assembly: ExportRenderer(typeof(WrappedTruncatedLabel), typeof(WrappedTruncatedLabelRenderer))]
namespace XFormsContosoCookbook.iOS
{
    public class WrappedTruncatedLabelRenderer : LabelRenderer
    {
        protected override void OnElementChanged(ElementChangedEventArgs<Label> e)
        {
            base.OnElementChanged(e);

            if (Control != null)
            {
                Control.LineBreakMode = UILineBreakMode.TailTruncation;
                Control.Lines = 0;
            }
        }
    }
}

The OnElementChanged override first calls the base class to create a UILabel control. It then sets the control’s LinebreakMode and Lines properties to wrap and truncate the text that the UILabel displays. The renderer sets the stage, and UILabel does the rest. Couldn’t be much easier than that.

Wrapping and Truncating Text in Android

Wrapping and truncating text is slightly more challenging on Android, but not overly difficult once you know the trick. Here’s the Android renderer for the WrappedTruncatedLabel control:

[assembly: ExportRenderer(typeof(WrappedTruncatedLabel), typeof(WrappedTruncatedLabelRenderer))]
namespace XFormsContosoCookbook.Droid
{
    public class WrappedTruncatedLabelRenderer : LabelRenderer
    {
        protected override void OnElementChanged(ElementChangedEventArgs<Label> e)
        {
            base.OnElementChanged(e);

            if (Control != null)
            {
                Control.LayoutChange += (s, args) =>
                {
                    Control.Ellipsize = Android.Text.TextUtils.TruncateAt.End;
                    Control.SetMaxLines((int)((args.Bottom - args.Top) / Control.LineHeight));
                };
            }
        }
    }
}

The basic idea is to wait for the TextView control rendered by LabelRenderer to fire a LayoutChanged event so you can obtain the TextView’s size. Then you set the control’s Ellipsize property to Android.Text.TextUtils.TruncateAt.End to truncate the text, and call SetMaxLines to set the control’s maximum height. That height is determined by dividing the control’s height (args.Bottom – args.Top) by the height of each line of text (Control.LineHeight). The result? Wrapped, truncated text that fits neatly into the space allocated for the control.

Wrapping and Truncating Text in Windows Phone

Of the three platforms, Windows Phone is the only one that provides an explicit means for wrapping and truncating text. In Windows Phone, the TextBlock control exposes properties named TextWrapping and TextTrimming that allow wrapping and trimming (truncating) to be controlled independently. When I set out to write WrappedTruncatedLabel, I thought the renderer for Windows Phone would be the simplest of the three. I was wrong.

In theory, a renderer for wrapped, truncated text on Windows Phone should be no more complicated than this:

[assembly: ExportRenderer(typeof(WrappedTruncatedLabel), typeof(WrappedTruncatedLabelRenderer))]
namespace XFormsContosoCookbook.WinPhone
{
    public class WrappedTruncatedLabelRenderer : LabelRenderer
    {
        protected override void OnElementChanged(ElementChangedEventArgs<Label> e)
        {
            base.OnElementChanged(e);

            if (Control != null)
            {
                Control.TextWrapping = System.Windows.TextWrapping.Wrap;
                Control.TextTrimming = System.Windows.TextTrimming.WordEllipsis;
            }
        }
    }
}

But it doesn’t work in Xamarin Forms. The short story is that a Windows Phone TextBlock in a Windows Phone Grid cell will wrap and truncate text just fine, but a Windows Phone TextBlock in a Xamarin Forms Grid cell will not. So I built the renderer to inject a Windows Phone Grid into the layout and reparent the TextBlock to the Grid:

[assembly: ExportRenderer(typeof(WrappedTruncatedLabel), typeof(WrappedTruncatedLabelRenderer))]
namespace XFormsContosoCookbook.WinPhone
{
    public class WrappedTruncatedLabelRenderer : LabelRenderer
    {
        protected override void OnElementChanged(ElementChangedEventArgs<Label> e)
        {
            base.OnElementChanged(e);

            if (Control != null)
            {
                Control.TextWrapping = System.Windows.TextWrapping.Wrap;
                Control.TextTrimming = System.Windows.TextTrimming.WordEllipsis;

                Control.Loaded += (s, args) =>
                {
                    var parent = Control.Parent as WrappedTruncatedLabelRenderer;

                    if (parent != null)
                    {
                        var grid = new System.Windows.Controls.Grid();
                        parent.Children.Remove(Control);
                        parent.Children.Add(grid);
                        grid.Children.Add(Control);
                    }
                };
            }
        }
    }
}

It’s a bit of a hack, but it works – at least in the current version of Xamarin Forms. If it breaks in the future, I’ll come back and modify it. But for now, this is the only one of many approaches I tried that works reliably.

Summary

You can download the source code for Contoso Cookbook and tweak it to your liking. Feel free to use WrappedTruncatedLabel and the accompanying renderers in projects of your own if you need wrapped, truncated text. No need to reinvent the wheel when you can use one that’s already built.

As a reminder, I’ll be delivering a talk on building cross-platform mobile apps with Xamarin Forms at the Software Design & Development conference in London next month. I’ll cover the basics of Xamarin Forms programming, and I’ll touch on several advanced topics, including custom renderers. Xamarin’s Mark Smith will be there, too. It will be a great opportunity to get off to a fast start with Xamarin and Xamarin Forms – and to go home with lots of sample code to keep the party going long after the final session has ended.

 

Xamarin-Partner_thumb1_thumb_thumb

Need Xamarin Help?

Xamarin Consulting  Xamarin Training

Stay Informed

Sign up for the latest blogs, events, and insights.

We deliver solutions that accelerate the value of Azure.
Ready to experience the full power of Microsoft Azure?

Atmosera is thrilled to announce that we have been named GitHub AI Partner of the Year.

X