Using Layout protocol to align explicitly positioned views in SwiftUI

We've just released a big update to our app Exsto, and one of the main additions was an interactive tutorial for new users. The tutorial helps users make their first strokes to start creating their own artworks. For this tutorial we needed to add a moving label that changes its position based on where the next stroke should start. To be able to animate the label as it changes its location, we added it in an overlay over the entire screen.

Screenshots of Exsto's onboarding.

The position() modifier in SwiftUI lets us put a view inside its parent frame by setting the views center to a given (x, y) coordinate. However, in our use case, we don't want our label to be centered on its anchor and to obscure the view it's labeling, so we need it to be right or left aligned to the anchor point.

A common workaround in iOS 15 and earlier is to overlay the presented view with a GeometryReader that measures its size and adjusts the offset accordingly. But reading these values is not ideal as it creates a cycle where the offset is updated only after the size is read. It results in inconsistent animations, in particular if we are also animating the label's text and size while moving it to the next control.

The new Layout protocol in iOS 16 lets us place views explicitly, and unlike the position() modifier, we can specify an anchor point when we call the place() method in placeSubviews().

We have different ways to use the Layout protocol. One advanced option is to have each overlay provide a layout value to a common layout that places multiple views. But in our case, we only have one annotation, so we used a simpler option where each annotation has its own layout.

# Building the layout

First we need to build a simple AnchoredPosition layout.

struct AnchoredPosition: Layout {
    let location: CGPoint
    let anchor: UnitPoint

    func sizeThatFits(
        proposal: ProposedViewSize, 
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        return proposal.replacingUnspecifiedDimensions()
    }
    
    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        let locationInBounds = CGPoint(
            x: location.x + bounds.origin.x,
            y: location.y + bounds.origin.y
        )
        
        for view in subviews {
            view.place(
                at: locationInBounds, 
                anchor: anchor, 
                proposal: proposal
            )
        }
    }
}

Now AnchoredPosition can be used to replace the position() modifier.

AnchoredPosition(location: anchorPoint, anchor: .leading) {
    OnBoardingCalloutLabel()
}
Integrating SwiftUI into UIKit Apps by Natalia Panferova book coverIntegrating SwiftUI into UIKit Apps by Natalia Panferova book cover

Check out our book!

Integrating SwiftUI into UIKit Apps

Integrating SwiftUI intoUIKit Apps

UPDATED FOR iOS 17!

A detailed guide on gradually adopting SwiftUI in UIKit projects.

  • Discover various ways to add SwiftUI views to existing UIKit projects
  • Use Xcode previews when designing and building UI
  • Update your UIKit apps with new features such as Swift Charts and Lock Screen widgets
  • Migrate larger parts of your apps to SwiftUI while reusing views and controllers built in UIKit

# Dynamically adjusting for available space

The Layout protocol also allows us to check the available space and the space required by the view. With the space needed we can dynamically change the anchor, for example, from leading to trailing.

For the leading or trailing case, we can modify the placeSubviews() method.

func placeSubviews(
    in bounds: CGRect,
    proposal: ProposedViewSize, 
    subviews: Subviews, 
    cache: inout ()
) {
    let locationInBounds = CGPoint(
        x: location.x + bounds.origin.x,
        y: location.y + bounds.origin.y
    )
    
    for view in subviews {
        if anchor == .leading,
            location.x + view.sizeThatFits(proposal).width > bounds.width {
            view.place(
                at: locationInBounds,
                anchor: .trailing,
                proposal: proposal
            )
        } else {
            view.place(
                at: locationInBounds,
                anchor: anchor,
                proposal: proposal
            )
        }    
    }
}

We can also pass the remaining size to the view when we place it, so that the view can adjust its size accordingly. For that we need a new method remainingProposedSize() that returns the new proposed size.

func remainingProposedSize(
        in bounds: CGRect,
        for anchor: UnitPoint
) -> ProposedViewSize {
    var newProposal = ProposedViewSize(
        width: bounds.width,
        height: bounds.height
    )
    
    let leadingWidth = bounds.width - location.x
    let topHeight = bounds.height - location.y
    
    switch anchor {
    case .bottom:
        newProposal.height = location.y
    case .bottomLeading:
        newProposal.height = location.y
        newProposal.width = leadingWidth
    case .bottomTrailing:
        newProposal.height = location.y
        newProposal.width = location.x
    case .center, .zero:
        break
    case .leading:
        newProposal.width = leadingWidth
    case .top:
        newProposal.height = topHeight
    case .topLeading:
        newProposal.height = topHeight
        newProposal.width = leadingWidth
    case .topTrailing:
        newProposal.height = topHeight
        newProposal.width = location.x
    case .trailing:
        newProposal.width = location.y
    default:
        break
    }
    return newProposal
}

We start by using the new remainingProposedSize() method to check if a given anchor is feasible or if we need to switch to an alternative.

func alternativeAnchor(
    for view: LayoutSubview,
    in bounds: CGRect
) -> UnitPoint? {
 
    let viewSize = view.sizeThatFits(
        remainingProposedSize(in: bounds, for: self.anchor)
    )
    
    switch anchor {
    case .center, .zero:
        return nil
    case .top:
        if viewSize.height + location.y > bounds.height {
            return .bottom
        }
    case .bottom:
        if viewSize.height > location.y {
            return .top
        }
    case .bottomLeading:
        if viewSize.width + location.x > bounds.width {
            return .bottomTrailing
        }
    case .bottomTrailing:
        if viewSize.width > location.x {
            return .bottomLeading
        }
    case .topLeading:
        if viewSize.width + location.x > bounds.width {
            return .topTrailing
        }
    case .topTrailing:
        if viewSize.width > location.x {
            return .topLeading
        }
    case .leading:
        if viewSize.width + location.x > bounds.width {
            return .trailing
        }
    case .trailing:
        if viewSize.width > location.x {
            return .leading
        }
    default:
        return nil
    }
    return nil
}

Then we can use alternativeAnchor() and remainingProposedSize() in our placeSubviews() method.

func placeSubviews(
    in bounds: CGRect,
    proposal: ProposedViewSize, 
    subviews: Subviews, 
    cache: inout ()
) {

    let locationInBounds = CGPoint(
        x: location.x + bounds.origin.x,
        y: location.y + bounds.origin.y
    )
    
    for view in subviews {
        if let alternativeAnchor = alternativeAnchor(
            for: view,
            in: bounds
        ) {
            view.place(
                at: locationInBounds,
                anchor: alternativeAnchor,
                proposal: remainingProposedSize(
                    in: bounds, 
                    for: alternativeAnchor
                )
            )
        } else {
            view.place(
                at: locationInBounds,
                anchor: anchor,
                proposal: remainingProposedSize(
                    in: bounds,
                    for: anchor
                )
            )
        }
    }
}

There are endless further improvements we could make, such as detecting if both leading and trailing are impossible and falling back to top or bottom alignment.

You can find the code in a sample project on GitHub.