The weird cases of text ellipsis

While working on a SwiftUI app I had to design views that hold more than a few lines of text inside a VStack. As I had almost no issues with layouting so far I expected the Text to grow as expected, but in some cases (but not others) the text was cut off at the end:

The view used to render this part of the UI is written like this:

struct MissionView: View {
  var mission: Mission
  var body: some View {
    VStack(alignment: .leading) {
      Text(mission.name)				
        .font(.system(.headline, design: .rounded))
        .foregroundColor(.label)
        .padding(.bottom, 8)
	.lineLimit(2)
	
      Text(mission.description)
        .font(.subheadline)
        .foregroundColor(
          .placeholderText)
    }
}

At first glance it seems the Text element has an implicit lineLimit. So I added a .lineLimit(.max). But that didn’t change the outcome.

After some digging I found out about .fixedSize(horizontal: Bool, vertical: Bool). This allows us to fix the size of the Text element in only one direction – vertical in our case.
After adding .fixedSize(horizontal: false, vertical: true) the text is not cut off and behaves as intended.

Image showing fixed version with now cut off

Bringing the simplicity of NavigationLink to popovers

In this article we explore the possibility to extend SwiftUIs APIs to make the common use case of presenting a popover easier and in line of NavigationLink.

I love the simple design of the NavigationLink view in SwiftUI. Only specifying the destination view and a label gets you fully functional navigation in the hosting NavigationView.

NavigationLink(destination: Text("This is the subview")) {
    Text("Go to subview")
}

But pushing a new view to the navigation stack is not the only interesting way to present new views. Modal sheets or popovers also have their use cases! Unfortunately the API for sheets and popovers is a little less straightforward to use, as it involves introducing a state variable and updating it from e.g. a button:

struct ContentView: View {
    @State var isShowingPopover = false
    
    var body: some View {
        Button {
            isShowingPopover = true
        } label: {
            Text("Go to subview")
        }
        .popover(isPresented: $isShowingPopover) {
            Text("Go to subview")
        }
    }
}

While this is still relatively concise and exposes access to dismissing the popover, it still requires introducing state even though this might not be needed.

So I asked myself: Is it possible to achieve the same API as NavigationLink but for popovers?
It turns out the answer is yes, and the solution is remarkably clean and easy to implement.

We start by implementing our own view that leans on the API of NavigationLink:

struct PopoverLink<Label, Destination> : View where Label : View, Destination : View {
    /// Creates an instance that presents `destination`.
    public init(destination: Destination, @ViewBuilder label: () -> Label)

    /// Creates an instance that presents `destination` when active.
    public init(destination: Destination, isActive: Binding<Bool>, @ViewBuilder label: () -> Label)
}

This interface is adapted from NavigationLink (but leaves out one initializer that I never used). Now to implementing this interface.
First we have to introduce variables for destination, label and isActive and assign these in the initializers. We also have to introduce an internal state variable that is used if the initializer without specific binding is used.

struct PopoverLink<Label, Destination> : View where Label : View, Destination : View {
    private let destination: Destination
    private let label: Label
    private var isActive: Binding<Bool>?
    @State private var internalIsActive = false

	/// Creates an instance that presents `destination`.
	public init(destination: Destination, @ViewBuilder label: () -> Label) {
        self.destination = destination
        self.label = label()
    }

    /// Creates an instance that presents `destination` when active.
    public init(destination: Destination, isActive: Binding<Bool>, @ViewBuilder label: () -> Label) {
        self.destination = destination
        self.label = label()
        self.isActive = isActive
    }
}

Last but not least we have to implement the body of our view. Here we can lean on our previous implementation of a popover and essentially do the same thing in the body of our view:

var body: some View {
    Button {
        (isActive ?? _internalIsActive.projectedValue).wrappedValue = true
    } label: {
        label
    }.popover(isPresented: (isActive ?? _internalIsActive.projectedValue)) {
        destination
    }
}

Note the use of optional chaining to pick the right binding depending on which initializer was used!

Finally we can fix the popover animation bug and present a sheet instead on compact layouts:

@Environment(\.horizontalSizeClass) var horizontalSizeClass

private func popoverButton() -> some View {
    Button {
        (isActive ?? _internalIsActive.projectedValue).wrappedValue = true
    } label: {
        label
    }
}

public var body: some View {
    if horizontalSizeClass == .compact {
        popoverButton().sheet(isPresented: (isActive ?? _internalIsActive.projectedValue)) {
            destination
        }
    } else {
        popoverButton().popover(isPresented: (isActive ?? _internalIsActive.projectedValue)) {
            destination
        }
    }
}

With just a little code we introduced our own PopoverLink implementation that allows us easily present modal popovers in SwiftUI using easy and familiar syntax from NavigationLink:

PopoverLink(destination: Text("This is the subview")) {
    Text("Go to subview")
}

This article shows how easy it is to introduce own views to SwiftUI to simplify common UI use cases and to improve on the existing SwiftUI library.

Below you can find the full source code for PopoverLink.