In this series of posts I am going to be building out the UI for Airbnb's "Explore" tab from scratch. This is an exercise in figuring out how to achieve the different layouts and effects you find in popular apps and I chose Airbnb because I thought it had several great examples. It is not intended to be used for anything other than for educational purposes and all my code will be available at this repo if you want to follow along and build it yourself. A few disclaimers:
- For all the illustrations in the app I just took screenshots of the actual app and cropped them to size. They are not the full versions of the illustrations and they are not really even formatted correctly for use in an iOS app. I tried to spend as little time as possible on that prep.
- We will get the fonts, colors and icons as close as we can with just using the system versions. I am pretty sure the font and icons that Airbnb actually uses would require a license and I don't care that much about making this exact. With the way we organize it, it would be pretty easy to swap in the real ones if you want to take that step yourself.
- We will not do any business logic. We will hard code all the data and not handle any user actions. Again, this is not meant to be production code, just an exploration of some UI techniques.
- There is a good chance that Airbnb will look different by the time you see this. Honestly, there's a non-zero chance that it will change before I finish writing this, so the live app will probably look different than what you see me build, but the layout/principles should be pretty much the same. (Editorial note: it has already changed before I was able to finish writing this series, but I have a few screenshots of what the app looked like before, and we'll just build up to the spec that I created.)
Here's what our final product will look like:
With all that said, let's go.
Animating The Header
Now that we have the collection view built out, the only thing left to do is deal with the header. Let's start by laying that out. It basically consists of a "card" with a background image, and search bar which has its own little container, a big title label with a button and a separator at the bottom to separate it from the collection view.
// In HeaderView.swift
private let card = UIView()
private let imageView = UIImageView()
private let searchContainer = UIView()
private let searchBar = UIButton(type: .roundedRect)
private let titleLabel = UITextView()
private let button = UIButton(type: .roundedRect)
private let separator = UIView()
A couple of things to call out. The "search bar" is just a button. I'm doing that primarily because it is what the Airbnb app is doing. If you go look in the app you'll find if you tap on the search bar it is just a button that navigates you to a "search view". And the title label is actually a text view. I went with that because it was the easiest way I could find to get the line height to look the way it is in the Airbnb app. You'll see why in a moment.
Next, we need to do some updates so these things all look like they should:
// In HeaderView.swift
// in configure()
backgroundColor = .black
card.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMinXMinYCorner]
card.layer.cornerRadius = 20
card.layer.masksToBounds = true
imageView.contentMode = .scaleAspectFill
imageView.image = UIImage(named: "background")
searchContainer.backgroundColor = .systemBackground
searchContainer.setBackgroundAlpha(0)
searchBar.backgroundColor = .secondarySystemBackground
searchBar.layer.cornerRadius = 22
searchBar.setTitle("Where are you going?", for: .normal)
searchBar.setTitleColor(.label, for: .normal)
searchBar.setImage(UIImage(systemName: "magnifyingglass"), for: .normal)
searchBar.tintColor = .systemPink
searchBar.titleLabel?.font = .custom(style: .button)
searchBar.imageView?.contentMode = .scaleAspectFit
searchBar.imageEdgeInsets = .init(top: 14, left: 0, bottom: 14, right: 4)
titleLabel.text = "Go\nNear"
titleLabel.textColor = .white
titleLabel.font = .custom(style: .largeTitle)
titleLabel.setLineHeightMultiple(to: 0.7)
titleLabel.isScrollEnabled = false
titleLabel.isEditable = false
titleLabel.isSelectable = false
titleLabel.backgroundColor = .clear
button.backgroundColor = .secondarySystemBackground
button.layer.cornerRadius = 6
button.setTitle("Explore nearby stays", for: .normal)
button.setTitleColor(.label, for: .normal)
button.titleLabel?.font = .custom(style: .button)
button.contentEdgeInsets = .init(top: 8, left: 8, bottom: 8, right: 8)
separator.backgroundColor = .quaternaryLabel
// in constrain()
addSubviews(card, titleLabel, button, separator)
card.addSubviews(imageView, searchContainer)
searchContainer.addSubview(searchBar)
heightAnchor == 600
card.topAnchor == 40
card.horizontalAnchors == horizontalAnchors
card.bottomAnchor == bottomAnchor
imageView.edgeAnchors == card.edgeAnchors
searchContainer.topAnchor == card.topAnchor
searchContainer.horizontalAnchors == card.horizontalAnchors
searchContainer.heightAnchor >= 60
searchBar.heightAnchor == 44
searchBar.topAnchor >= searchContainer.topAnchor + 24
searchBar.topAnchor >= safeAreaLayoutGuide.topAnchor + 12
searchBar.horizontalAnchors == searchContainer.horizontalAnchors + 24
searchBar.bottomAnchor == searchContainer.bottomAnchor - 12
titleLabel.topAnchor == safeAreaLayoutGuide.topAnchor + 160
titleLabel.leadingAnchor == leadingAnchor + 24
button.topAnchor == titleLabel.bottomAnchor
button.leadingAnchor == titleLabel.leadingAnchor
separator.heightAnchor == 1
separator.horizontalAnchors == horizontalAnchors
separator.bottomAnchor == bottomAnchor
You'll notice that we're using a couple of new fonts here, and a few new helper methods that we haven't written yet. Let's add the button font and the large title font:
// In Extensions/UIFont+Custom.swift
private static var largeTitle: UIFont {
return UIFontMetrics(forTextStyle: .largeTitle)
.scaledFont(for: .systemFont(ofSize: 64, weight: .heavy))
}
private static var button: UIFont {
UIFontMetrics(forTextStyle: .headline)
.scaledFont(for: .systemFont(ofSize: 13, weight: .semibold))
}
// in Style
case largeTitle
case button
// other cases...
// in custom(style:)
case .largeTitle: return largeTitle
case .button: return button
Then we'll add a helper on UIView
that will just let us adjust the alpha component of the background a little more concisely.
// In Extensions/UIView+Helpers.swift
func setBackgroundAlpha(_ alpha: CGFloat) {
backgroundColor = backgroundColor?.withAlphaComponent(alpha)
}
Finally, we'll add a helper on UITextView
which will allow us to set the line height multiple to match the design.
// In Extensions/UITextView+Helpers.swift
func setLineHeightMultiple(to lineHeightMultiple: CGFloat = 0.0) {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineHeightMultiple = lineHeightMultiple
let attributedString: NSMutableAttributedString
if let attributedText = attributedText {
attributedString = NSMutableAttributedString(attributedString: attributedText)
} else if let text = text {
attributedString = NSMutableAttributedString(string: text)
} else {
return
}
attributedString.addAttribute(.paragraphStyle,
value:paragraphStyle,
range:NSMakeRange(0, attributedString.length))
attributedText = attributedString
}
If you run the app right now you'll see that it looks pretty close to the spec.
One difference is that the space above the card is not quite right. That is because we're not accounting for the safe area yet. Let's do that now.
// In HeaderView.swift
// add a properties for holding the height and a reference to the constraint
private var maxTopSpace: CGFloat = 40
private var topSpaceConstraint = NSLayoutConstraint()
// capture the reference in constrain()
topSpaceConstraint = card.topAnchor == topAnchor + maxTopSpace
// update the height and constraint whenever the safe area insets change
override func safeAreaInsetsDidChange() {
maxTopSpace = 40 + safeAreaInsets.top
topSpaceConstraint.constant = maxTopSpace
}
Another difference is that the status bar isn't the right color. We'll need to fix that in HomeViewController
:
// In HomeViewController.swift
private var statusBarStyle: UIStatusBarStyle = .lightContent
override var preferredStatusBarStyle: UIStatusBarStyle {
statusBarStyle
}
Making It Collapsible
Now it looks like it should, but if you scroll the collection view, you'll find that it is stuck at the size it is. Let's fix that.
We're going to add a method called updateHeader
which will be what other views will call when they want the header to change size. It will take the old Y offset and the new Y offset and it will return a CGFloat
which reflects an updated Y offset. We need to return the updated offset so the caller (the scrollview) can update its offset as its size changes. If you don't do this the scroll will look a little "bouncy" under certain circumstances. I know that is a lot, but it will all fall into place as we put the pieces together. For now, it looks like this:
// In HeaderView.swift
func updateHeader(newY: CGFloat, oldY: CGFloat) -> CGFloat {
// do calculations and update view
return newY
}
Now we need to call that method, which we're going to do in HomeView
in a UIScrollViewDelegate
method called scrollViewDidScroll(_:)
// In HomeView.swift
private var oldYOffset: CGFloat = 0
// in configure()
collectionView.delegate = self
// at bottom of file
extension HomeView: UICollectionViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let yOffset = scrollView.contentOffset.y
let updatedY = headerView.updateHeader(newY: yOffset, oldY: oldYOffset)
scrollView.contentOffset.y = updatedY
oldYOffset = scrollView.contentOffset.y
}
}
You can see that we are grabbing the current Y offset, passing it and the old one to updateHeader
and using the returned CGFloat
to update the scroll view's offset. Finally, we update the old offset to be the current one. Right now you can run the app and you'll find that it runs but doesn't do anything different. Which is what we'd expect. So far, we've just added some unnecessary function calls that pass around numbers. But now, let's make it collapse.
If we think about it for a second, we'll realize that there are three criteria we need to meet in order to collapse:
- We need the content to be moving up. The user's finger needs to be moving the scrollview upwards.
- We need for to be within the bounds of the content, not beyond it. (This is more important in the expanding logic.)
- We need for there to be room to continue collapsing the header. If it is already as small as it can go, we can't collapse any more.
// In HeaderView.swift
// in updateHeader(newY:oldY)
let delta = newY - oldY
let isMovingUp = delta > 0
let isInContent = newY > 0
let hasRoomToCollapse = currentOffset > minHeight
let shouldCollapse = isMovingUp && isInContent && hasRoomToCollapse
if shouldCollapse {
currentOffset -= delta
return newY - delta
}
This won't compile yet, because we need to add a couple of header variables, but you should be able to see the logic. If we meet all the criteria for collapsing, we'll shrink the header by the amount the user has moved their finger, and we'll return an updated offset so the scroll view can move along with it.
Now we need to add a few more things to get this to work. We'll add a minHeight
and maxHeight
to keep track of the bounds of our header. We'll also add a heightConstraint
variable, so we can update the constrained height as we need to. Finally, we'll add currentOffset
which will just be a wrapper around the height constraint and which will call our animation function any time it changes.
// In HeaderView.swift
private lazy var minHeight: CGFloat = { 44 + 12 + 12 + safeAreaInsets.top }()
private let maxHeight: CGFloat = 600
private var heightConstraint = NSLayoutConstraint()
// in constrain()
heightConstraint = heightAnchor == maxHeight
// in animation extension
private var currentOffset: CGFloat {
get { heightConstraint.constant }
set { animate(to: newValue) }
}
private func animate(to value: CGFloat) {
let clamped = max(min(value, maxHeight), minHeight)
heightConstraint.constant = clamped
}
With that, you should be able to run the app and see the header collapse as you scroll the collection view up.
Expanding The Header
You may notice that it is only one way right now. You can collapse the header, but you can't expand it back without restarting the app. That will be the next problem we tackle. Again, we have three criteria we need to meet.
- We need the content to be moving down.
- We need to be beyond the scrollview's content. This means we won't expand the header any time the user scrolls down, we'll only expand it if they are at the end of the content.
- We need to have room to expand the header.
// In HeaderView.swift
// in updateHeader(newY:oldY:)
let isMovingDown = delta < 0
let isBeyondContent = newY < 0
let hasRoomToExpand = currentOffset < maxHeight
let shouldExpand = isMovingDown && isBeyondContent && hasRoomToExpand
if shouldCollapse || shouldExpand {
currentOffset -= delta
return newY - delta
}
The logic for what we need to do if we want to expand will be the same as when we collapse, because the delta will be negative so it will have the effect of adding to the currentOffset
. That means we can put it all in one if block.
Polishing The Animation
Now we've got it working, but it does not quite match the spec. The card needs to move up and the search container needs to fade in, but neither of them start until we're halfway through the movement. So let's take a couple of steps to match that. First, let's get a normalized value of where we're at in the transition. That means a number from 0.0 to 1.0 which will reflect what percentage of the way through we currently are. Then, we'll use that to break out into a function that will handle animations 0-50% and another that will handle 50-100%.
// In HeaderView.swift
//in animate(to:)
let normalized = (value - minHeight) / (maxHeight - minHeight)
switch normalized {
case ..<0.5:
animateToFifty(normalized)
default:
animateToOneHundred(normalized)
}
private func animateToFifty(_ normalized: CGFloat) {
let newTop = normalized * 2 * maxTopSpace
topSpaceConstraint.constant = newTop
searchContainer.setBackgroundAlpha(1 - normalized * 2)
}
private func animateToOneHundred(_ normalized: CGFloat) {
topSpaceConstraint.constant = maxTopSpace
searchContainer.setBackgroundAlpha(0)
}
Updating The Status Bar
Now that's looking pretty good! The last thing that we need to fix is the status bar. It doesn't automatically update when we put white content behind it, so we need to do that update ourselves. As we discussed earlier though, we need to handle that in the view controller. So we'll need to send delegate methods up to let the view controller know that it might want to update the status bar. First, let's define a delegate protocol, add a delegate to our view and a local property to keep track of what the view thinks the style should be.
// In HeaderView.swift
protocol HeaderViewDelegate: AnyObject {
func updateStatusBarStyle(to style: UIStatusBarStyle)
}
//in HeaderView
weak var delegate: HeaderViewDelegate?
private var statusBarStyle: UIStatusBarStyle = .lightContent {
didSet { delegate?.updateStatusBarStyle(to: statusBarStyle) }
}
Then we need to move up a layer to the HomeView
and do the same thing, to pass the message along:
// In HomeView.swift
protocol HomeViewDelegate: AnyObject {
func updateStatusBarStyle(to style: UIStatusBarStyle)
}
// in HomeView
weak var delegate: HomeViewDelegate?
// in configure()
headerView.delegate = self
// at the bottom of the file
extension HomeView: HeaderViewDelegate {
func updateStatusBarStyle(to style: UIStatusBarStyle) {
delegate?.updateStatusBarStyle(to: style)
}
}
Then we need to adopt the delegate protocol in the HomeViewController
// In HomeViewController.swift
// in loadView()
contentView.delegate = self
// at the bottom of the file
extension HomeViewController: HomeViewDelegate {
func updateStatusBarStyle(to style: UIStatusBarStyle) {
statusBarStyle = style
UIView.animate(withDuration: 0.4) {
self.setNeedsStatusBarAppearanceUpdate()
}
}
}
Finally, we just need to make a check in animateToFifty
to see if we should change the status bar style or not.
// In HeaderView.swift
if newTop < 24 && statusBarStyle != .darkContent {
statusBarStyle = .darkContent
} else if newTop > 24 && statusBarStyle != .lightContent {
statusBarStyle = .lightContent
}
I picked 24 points as a rough estimate of when the card passes by the status bar. It's not perfect, but it still feels pretty good.
There is just one minor problem left, and it is one that the actual Airbnb app doesn't have to handle. In our app, we are supporting dark mode (as good iOS citizens should), but in even in dark mode we want the space behind the card to be black. That means the status bar style is right when the header is expanded (it should still be white), but it should also stay white when the header is collapsed, because the search container background is now black too. All we need to do to fix that is add a check in the view controller. If the new status bar style is light and the user interface style is dark, we'll just return early:
// In HomeViewController.swift
// in updateStatusBarStyle(to:)
if statusBarStyle == .lightContent && traitCollection.userInterfaceStyle == .dark {
return
}
And now it looks great! It matches the spec, and it even works nicely in dark mode.
Caveats
- There are still some parts of the animation that aren't exactly right. In the real version, it bounces past the final height of the header if you pull on it, and you can swipe in the header itself to do the animation. Both of those things should be accomplishable within the framework I've set up, but I'll leave that as an exercise for you.
- In the actual Airbnb app the "Explore nearby stays" button animates a little bit when it is highlighted, rather than changing the color of the text. I didn't want to take the time to go into that here, but it should be pretty simple to add.
Wrap Up
In this article we laid out the header view, we looked at one technique for animating a header based on the user's interaction in a scroll view, and we looked at some techniques for customizing that animation that keep our code fairly straightforward and easy to read. This is the last article in this series and I hope that you learned something or at least had fun. See you in the next one!
Check out the code up to this point in this repo.
If this has been helpful buy me a coffee!
Top comments (0)