Writing an iOS Message Renderer

So for some context, I was developing an app for a business solutions company which was to include a full blown messaging system, equivilent to WhatsApp. The message list view would have to handle hundreds of messages at once, while feeling buttery smooth. After a lot of experimenting, I finally acheived this, and the process is documented here. All code given is written in C# since the project was written in Xamarin.IOS, but it should be easy to understand for a Swift developer.

The Cursed UIStackView

For simplicity’s sake, I started by simply adding all the messages as arranged subviews in a UIStackView. This made thigs very simple, as I just needed to call InsertArrangedSubview with the correct index. However, there is one problem with this. In order to make sure everything is positioned correcly, the UIStackView re-evaluates all the NSLayoutConstraints of each existing view every time the list is changed. On the surface, this seems sensible, however if we are adding or removing messages we don’t need to recompute all the positions of the other messages - we simply push the message to the bottom of the view or shift a section of existing views down to make space. This meant that it would literally take up to 7 seconds to add another message to the bottom of the UIStackView, even though absolutely none of the exisitng views in the UIStackView needed any layout update whatsoever.

The UITableView Alternative

I decided to do a bit of reading around the subject and I saw a few recommendations to try the UITableView approach, where each table cell represents a different message. This has a few benefits, the most notable one being that much less memory is consumed when a large number of messages are loaded at onoce. This is down to how the UITableView loads messages to the screen. A UITableView dynamically loads the cells on demand - this means only the views that are actually going to be displayed need to be in memory. While this sounds good in theory, unfortunately there are downsides to this approach. Firstly, there is no way for the UITableView to know the size of the view cells. Secondly, if we are scrolling quickly the dynamic message loading can be jittery and unpleasant. For this reason I decided this too would not suffice.

The Custom Approach

The only option left was to do write a completely custom view and layout the messages manually. This means that we only need to recalculate layout when absolutely necessary. The following functions are the layout functions of the custom UIView subclass. If you are using constraints the frame of the views added must be kept static (cannot assign constraint to parent’s width/height). This is purely to increases performance.

private void Push(UIView view)
{
    view.AutoresizingMask = UIViewAutoresizing.None;
    view.TranslatesAutoresizingMaskIntoConstraints = true;
    var frame = view.Frame;
    frame.Y = Frame.Height;
    frame.X = 0;
    frame.Width = Frame.Width;
    view.Frame = frame;

    var thisFrame = Frame;
    thisFrame.Height += view.Frame.Height;
    Frame = thisFrame;
    AddSubview(view);
}

private void Insert(UIView view, int idx)
{
    view.AutoresizingMask = UIViewAutoresizing.None;
    view.TranslatesAutoresizingMaskIntoConstraints = true;

    nfloat y = 0;
    if (idx > 0)
    {
        UIView last = Subviews[idx - 1];
        y = last.Frame.Bottom;
    }
    var frame = view.Frame;
    frame.Y = y;
    frame.X = 0;
    frame.Width = Frame.Width;
    view.Frame = frame;
    ShiftAfter(idx, frame.Height);
    InsertSubview(view, idx);
}

private void Insert(UIView[] views, int idx)
{
    nfloat y = 0;
    if (idx > 0)
    {
        UIView last = Subviews[idx - 1];
        y = last.Frame.Bottom;
    }
    nfloat yOffset = 0;
    for (int i = idx; i < idx + views.Length; i++)
    {
        var view = views[i];
        view.AutoresizingMask = UIViewAutoresizing.None;
        view.TranslatesAutoresizingMaskIntoConstraints = true;

        var frame = view.Frame;
        frame.Y = y + yOffset;
        frame.X = 0;
        frame.Width = Frame.Width;
        view.Frame = frame;

        InsertSubview(view, i);

        yOffset += frame.Height;
    }

    ShiftAfter((idx + views.Length) - 1, yOffset);
}

private void ShiftAfter(int idx, nfloat delta)
{
    for (int i = idx + 1; i < Subviews.Length; i++)
    {
        var view = Subviews[i];
        var frame = view.Frame;
        frame.Y += delta;

        view.Frame = frame;
    }
    var thisFrame = Frame;
    thisFrame.Height += delta;
    Frame = thisFrame;
}
Written on August 6, 2020