Building Avatars

Neil Sardesai
Frame.io Engineering
7 min readNov 12, 2021

Taming the supercomponent

If you’re building an app with any concept of user profiles, you probably use an avatar to represent users. An avatar is a common UI component in many apps, and it seems like it should be a relatively simple component — just a text label or an image with a circular mask.

An example of how avatars might appear in a list of users

And yet the avatar is often one of the most complex UI components in an iOS codebase. Why does this happen? Let’s try building a simple avatar component and see how its implementation might evolve over time.

Suppose the spec for our avatar component looks like this: an avatar can display a user’s initials or profile picture, and comes in three sizes — small, medium, and large.

A visual representation of the spec for the avatar component

Our implementation might look something like this:

class Avatar: UIView {    // MARK: - Properties    private let label: UILabel = { /* view setup… */ }()
private let imageView: UIImageView = { /* view setup… */ }()
// MARK: - Lifecycle override init(frame: CGRect) {
super.init(frame: frame)
addSubview(label)
addSubview(imageView)
// etc…
}
convenience init(
size: Size, initials: String, image: UIImage?
) {
self.init(frame: .zero)
update(.init(size: size, initials: initials, image: image))
}
func update(_ content: Content) {
label.text = content.initials
imageView.image = content.image
// etc…
}
}// MARK: - Modelsextension Avatar { enum Size {
case small
case medium
case large
}
struct Content {
let size: Size
let initials: String
let image: UIImage?
}
}

To update our component and make it render any new data, we use the update(_:) function. This function can take in any kind of model object appropriate for a given component, but usually a struct or enum works well. This is a pattern we’ve started using with all our components as it gives every component a consistent and predictable API. If you know how to use one of the components in our component library, you know how to use all of them. This pattern also guides usage. Because every component is typically a UIView, and often a subclass such as UIButton, components often come with a lot of capabilities that we may not actually want to support. For example, there’s technically nothing stopping someone using this component from modifying its backgroundColor or layer.cornerRadius. But with update(_:), we can say that only whatever goes through that function should be considered public API.

This looks pretty good. The implementation is straightforward, and using the component is also easy. To create the avatar for our list row from above, we can just:

let avatar = Avatar(
size: .medium,
initials: "NS",
image: UIImage(named: "neil")
)

However, sometime later, our designs change. Suppose we decide to show the user’s favorite emoji in the large size avatar.

An example of how the large size avatar may appear on a Profile screen

We only want to do this for the large size, and not for any of the other sizes. So we add a new optional property to our Content struct:

struct Content {
let size: Size
let initials: String
let image: UIImage?
let emoji: String?
}

And our previous avatar usage now looks like this:

let avatar = Avatar(
size: .medium,
initials: "NS",
image: UIImage(named: "neil"),
emoji: nil
)

A little more complex, but still not too bad. However, inevitably our designs change again. What if in lists, the avatar functioned as a button? We need to change our component’s base class from UIView to UIButton, update our Content struct again, and make sure the component only behaves like a button when it should.

class Avatar: UIButton {    func update(_ content: Content) {

accessibilityTraits = content.actionHandler == nil
? .image
: .button

}
}struct Content {

let actionHandler: UIActionHandler?
}

An app’s UI is rarely designed once and then set in stone forever. Designs evolve over time as requirements change. Suppose one day we need to show medium and small size avatars with online indicators in certain parts of the app. Later on we need a small size avatar with a notification indicator and more squarish appearance in one specific screen. With all these different capabilities, our Avatar component is now pretty complex.

A visual representation of the now more complex spec for the avatar component

Using the component requires dealing with several properties that may not be relevant in a particular situation:

let avatar = Avatar(
size: .medium,
initials: "NS",
image: UIImage(named: "neil"),
emoji: nil,
isNotificationIndicatorVisible: false,
onlineStatus: nil,
actionHandler: nil
)

And maintaining the implementation of Avatar is also a challenge. With a component that supports a large number of different behaviors, it’s easy to cause visual regressions across the app when adding new behaviors or modifying existing ones. Snapshot tests can help catch these kinds of regressions, and here at Frame.io, we use the Point-Free SnapshotTesting library for this exact reason. But wouldn’t it be great if it weren’t so easy to cause regressions in the first place?

Supercomponent — a single large, complex UI component that’s trying to do way too many different things

The problem with our avatar component is that it’s turned into a supercomponent — one giant generic component that does everything, making the component both difficult to use and difficult to maintain. We can solve both of these issues by breaking up the component into multiple use case-specific avatar components that only share base components as needed. Here, a base component refers to a component that generally isn’t meant to be used on its own, and is instead meant to be composed together with other base components to create an actual usable component.

Let’s start by grouping the avatars more semantically rather than just by visual appearance. We’ll keep the general purpose Small and Medium avatars, but since the large size avatar only appears on the Profile screen and has special behavior that the other avatars don’t, we’ll call this a Profile avatar. We’ll also create a Notification avatar, an avatar that functions as a button in lists, and an avatar that shows online status.

Reorganized visual representation of the new avatar spec

The only things that all these avatars have in common that we’d want to reuse across all the components are the label and image view, so let’s create base components for those.

enum AvatarBaseComponent {
class Label: UILabel { /* view setup… */ }
class ImageView: UIImageView { /* view setup… */ }
}

Then for the Small and Medium avatars, we just add the base components to the view hierarchy and compose them together.

class SmallAvatar: UIView {
private let label = AvatarBaseComponent.Label()
private let imageView = AvatarBaseComponent.ImageView()
// remaining view setup…
}
class MediumAvatar: UIView {
private let label = AvatarBaseComponent.Label()
private let imageView = AvatarBaseComponent.ImageView()
// remaining view setup…
}

For the Profile and Notification avatars, we can do the same thing while adding in behaviors that are unique to these avatars. And since these behaviors are isolated to these avatars, there is no chance that changing these behaviors can accidentally cause regressions in other avatars.

class ProfileAvatar: UIView {
private let label = AvatarBaseComponent.Label()
private let imageView = AvatarBaseComponent.ImageView()
private let emojiLabel: UILabel = {
let emojiLabel = UILabel()
// view setup…
return emojiLabel
}()
// remaining view setup…
}
class NotificationAvatar: UIView {
private let label = AvatarBaseComponent.Label()
private let imageView = AvatarBaseComponent.ImageView()
private let notificationIndicator: UIView = {
let notificationIndicator = UIView()
// view setup…
return notificationIndicator
}()
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = 8
// etc…
}
// etc…
}

We can even compose avatars together to create new avatars. For the List Button avatar, rather than changing the Medium avatar to function as a button, we can create a button that contains the Medium avatar.

class ListButtonAvatar: UIButton {
private let mediumAvatar = MediumAvatar()
// remaining view setup…
}

And lastly with the Online Status avatar, we can take a similar approach. This avatar is really just either the Small or Medium avatar along with an online status indicator that’s unique to this component.

class OnlineStatusAvatar: UIView {
private let smallAvatar = SmallAvatar()
private let mediumAvatar = MediumAvatar()
private let onlineStatusIndicator: UIView = {
let onlineStatusIndicator = UIView()
// view setup…
return onlineStatusIndicator
}()
// remaining view setup…
}

With these more use case-specific avatars, each avatar component is easier to maintain — reducing the likelihood of changes causing regressions, and easier to use since each avatar’s input Content struct now only needs to contain exactly what’s necessary for that particular avatar. Using our new List Button avatar just looks like this:

let avatar = ListButtonAvatar(
initials: "NS",
image: UIImage(named: "neil"),
actionHandler: { … }
)

Notice how someone using this component doesn’t need to care about the size of the avatar, emojis, notification indicators, or online status, since none of that is relevant to this type of avatar.

Maintaining a component library is an ongoing process that requires close collaboration between design and engineering. As designs and requirements change and a component needs to support more and more capabilities, it’s easy for a component to turn into a supercomponent. It may be tempting to create base components right from the get-go, but it’s difficult to know how requirements will change in the future, so trying to account for those early often isn’t possible and just creates needless abstraction. Instead, keep an eye on how a component’s capabilities are evolving and how the component is actually being used. Is a component being used in two semantically unrelated places just because two designs are visually similar? Is a component supporting many one-off special capabilities?

It may be time to start breaking it up into smaller pieces.

--

--