How to make view the size of another view in SwiftUI

I'm trying to recreate a portion of the Twitter iOS app to learn SwiftUI and am wondering how to dynamically change the width of one view to be the width of another view. In my case, to have the underline be the same width as the Text view.

I have attached a screenshot to try and better explain what I'm referring to. Any help would be greatly appreciated, thanks!

Also here is the code I have so far:

import SwiftUI

struct GridViewHeader : View {

    @State var leftPadding: Length = 0.0
    @State var underLineWidth: Length = 100

    var body: some View {
        return VStack {
            HStack {
                Text("Tweets")
                    .tapAction {
                        self.leftPadding = 0

                }
                Spacer()
                Text("Tweets & Replies")
                    .tapAction {
                        self.leftPadding = 100
                    }
                Spacer()
                Text("Media")
                    .tapAction {
                        self.leftPadding = 200
                }
                Spacer()
                Text("Likes")
            }
            .frame(height: 50)
            .padding(.horizontal, 10)
            HStack {
                Rectangle()
                    .frame(width: self.underLineWidth, height: 2, alignment: .bottom)
                    .padding(.leading, leftPadding)
                    .animation(.basic())
                Spacer()
            }
        }
    }
}


I have written a detailed explanation about using GeometryReader, view preferences and anchor preferences. The code below uses those concepts. For further information on how they work, check this article I posted: https://swiftui-lab.com/communicating-with-the-view-tree-part-1/

The solution below, will properly animate the underline:

enter image description here

I struggled to make this work and I agree with you. Sometimes, you just need to be able to pass up or down the hierarchy, some framing information. In fact, the WWDC2019 session 237 (Building Custom Views with SwiftUI), explains that views communicate their sizing continuously. It basically says Parent proposes size to child, childen decide how they want to layout theirselves and communicate back to the parent. How they do that? I suspect the anchorPreference has something to do with it. However it is very obscure and not at all documented yet. The API is exposed, but grasping how those long function prototypes work... that's a hell I do not have time for right now.

I think Apple has left this undocumented to force us rethink the whole framework and forget about "old" UIKit habits and start thinking declaratively. However, there are still times when this is needed. Have you ever wonder how the background modifier works? I would love to see that implementation. It would explain a lot! I'm hoping Apple will document preferences in the near future. I have been experimenting with custom PreferenceKey and it looks interesting.

Now back to your specific need, I managed to work it out. There are two dimensions you need (the x position and width of the text). One I get it fair and square, the other seems a bit of a hack. Nevertheless, it works perfectly.

The x position of the text I solved it by creating a custom horizontal alignment. More information on that check session 237 (at minute 19:00). Although I recommend you watch the whole thing, it sheds a lot of light on how the layout process works.

The width, however, I'm not so proud of... ;-) It requires DispatchQueue to avoid updating the view while being displayed. UPDATE: I fixed it in the second implementation down below

First implementation

extension HorizontalAlignment {
    private enum UnderlineLeading: AlignmentID {
        static func defaultValue(in d: ViewDimensions) -> CGFloat {
            return d[.leading]
        }
    }

    static let underlineLeading = HorizontalAlignment(UnderlineLeading.self)
}


struct GridViewHeader : View {

    @State private var activeIdx: Int = 0
    @State private var w: [CGFloat] = [0, 0, 0, 0]

    var body: some View {
        return VStack(alignment: .underlineLeading) {
            HStack {
                Text("Tweets").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 0))
                Spacer()
                Text("Tweets & Replies").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 1))
                Spacer()
                Text("Media").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 2))
                Spacer()
                Text("Likes").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 3))
                }
                .frame(height: 50)
                .padding(.horizontal, 10)
            Rectangle()
                .alignmentGuide(.underlineLeading) { d in d[.leading]  }
                .frame(width: w[activeIdx],  height: 2)
                .animation(.linear)
        }
    }
}

struct MagicStuff: ViewModifier {
    @Binding var activeIdx: Int
    @Binding var widths: [CGFloat]
    let idx: Int

    func body(content: Content) -> some View {
        Group {
            if activeIdx == idx {
                content.alignmentGuide(.underlineLeading) { d in
                    DispatchQueue.main.async { self.widths[self.idx] = d.width }

                    return d[.leading]
                }.onTapGesture { self.activeIdx = self.idx }

            } else {
                content.onTapGesture { self.activeIdx = self.idx }
            }
        }
    }
}

Update: Better implementation without using DispatchQueue

My first solution works, but I was not too proud of the way the width is passed to the underline view.

I found a better way of achieving the same thing. It turns out, the background modifier is very powerful. It is much more than a modifier that can let you decorate the background of a view.

The basic steps are:

  1. Use Text("text").background(TextGeometry()). TextGeometry is a custom view that has a parent with the same size as the text view. That is what .background() does. Very powerful.
  2. In my implementation of TextGeometry I use GeometryReader, to get the geometry of the parent, which means, I get the geometry of the Text view, which means I now have the width.
  3. Now to pass the width back, I am using Preferences. There's zero documentation about them, but after a little experimentation, I think preferences are something like "view attributes" if you like. I created my custom PreferenceKey, called WidthPreferenceKey and I use it in TextGeometry to "attach" the width to the view, so it can be read higher in the hierarchy.
  4. Back in the ancestor, I use onPreferenceChange to detect changes in the width, and set the widths array accordingly.

It may all sound too complex, but the code illustrates it best. Here's the new implementation:

import SwiftUI

extension HorizontalAlignment {
    private enum UnderlineLeading: AlignmentID {
        static func defaultValue(in d: ViewDimensions) -> CGFloat {
            return d[.leading]
        }
    }

    static let underlineLeading = HorizontalAlignment(UnderlineLeading.self)
}

struct WidthPreferenceKey: PreferenceKey {
    static var defaultValue = CGFloat(0)

    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }

    typealias Value = CGFloat
}


struct GridViewHeader : View {

    @State private var activeIdx: Int = 0
    @State private var w: [CGFloat] = [0, 0, 0, 0]

    var body: some View {
        return VStack(alignment: .underlineLeading) {
            HStack {
                Text("Tweets")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 0))
                    .background(TextGeometry())
                    .onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[0] = $0 })

                Spacer()

                Text("Tweets & Replies")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 1))
                    .background(TextGeometry())
                    .onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[1] = $0 })

                Spacer()

                Text("Media")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 2))
                    .background(TextGeometry())
                    .onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[2] = $0 })

                Spacer()

                Text("Likes")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 3))
                    .background(TextGeometry())
                    .onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[3] = $0 })

                }
                .frame(height: 50)
                .padding(.horizontal, 10)
            Rectangle()
                .alignmentGuide(.underlineLeading) { d in d[.leading]  }
                .frame(width: w[activeIdx],  height: 2)
                .animation(.linear)
        }
    }
}

struct TextGeometry: View {
    var body: some View {
        GeometryReader { geometry in
            return Rectangle().fill(Color.clear).preference(key: WidthPreferenceKey.self, value: geometry.size.width)
        }
    }
}

struct MagicStuff: ViewModifier {
    @Binding var activeIdx: Int
    let idx: Int

    func body(content: Content) -> some View {
        Group {
            if activeIdx == idx {
                content.alignmentGuide(.underlineLeading) { d in
                    return d[.leading]
                }.onTapGesture { self.activeIdx = self.idx }

            } else {
                content.onTapGesture { self.activeIdx = self.idx }
            }
        }
    }
}

First, to answer the question in the title, if you want to make a shape (view) fit to the size of another view, you can use an .overlay(). The .overlay() gets offered its size from the view it is modifying.

In order to set offsets and widths in your Twitter recreation, you can use a GeometryReader. The GeometryReader has the ability to find its .frame(in:) another coordinate space.

You can use .coordinateSpace(name:) to identify the reference coordinate space.

struct ContentView: View {
    @State private var offset: CGFloat = 0
    @State private var width: CGFloat = 0
    var body: some View {
        HStack {
            Text("Tweets")
                .overlay(MoveUnderlineButton(offset: $offset, width: $width))
            Text("Tweets & Replies")
                .overlay(MoveUnderlineButton(offset: $offset, width: $width))
            Text("Media")
                .overlay(MoveUnderlineButton(offset: $offset, width: $width))
            Text("Likes")
                .overlay(MoveUnderlineButton(offset: $offset, width: $width))
        }
        .coordinateSpace(name: "container")
        .overlay(underline, alignment: .bottomLeading)
        .animation(.spring())
    }
    var underline: some View {
        Rectangle()
            .frame(height: 2)
            .frame(width: width)
            .padding(.leading, offset)
    }
    struct MoveUnderlineButton: View {
        @Binding var offset: CGFloat
        @Binding var width: CGFloat
        var body: some View {
            GeometryReader { geometry in
                Button(action: {
                    self.offset = geometry.frame(in: .named("container")).minX
                    self.width = geometry.size.width
                }) {
                    Rectangle().foregroundColor(.clear)
                }
            }
        }
    }
}
  1. The underline view is is a 2 point high Rectangle, put in an .overlay() on top of the HStack.
  2. The underline view is aligned to .bottomLeading, so that we can programmatically set its .padding(.leading, _) using a @State value.
  3. The underline view's .frame(width:) is also set using a @State value.
  4. The HStack is set as the .coordinateSpace(name: "container") so we can find the frame of our buttons relative to this.
  5. The MoveUnderlineButton uses a GeometryReader to find its own width and minX in order to set the respective values for the underline view
  6. The MoveUnderlineButton is set as the .overlay() for the Text view containing the text of that button so that its GeometryReader inherits its size from that Text view.

Segmented with Underbar in action


Give this a try:

import SwiftUI

var titles = ["Tweets", "Tweets & Replies", "Media", "Likes"]

struct GridViewHeader : View {

    @State var selectedItem: String = "Tweets"

    var body: some View {
        HStack(spacing: 20) {
            ForEach(titles.identified(by: \.self)) { title in
                HeaderTabButton(title: title, selectedItem: self.$selectedItem)
                }
                .frame(height: 50)
        }.padding(.horizontal, 10)

    }
}

struct HeaderTabButton : View {
    var title: String

    @Binding var selectedItem: String

    var isSelected: Bool {
        selectedItem == title
    }

    var body: some View {
        VStack {
            Button(action: { self.selectedItem = self.title }) {
                Text(title).fixedSize(horizontal: true, vertical: false)

                Rectangle()
                    .frame(height: 2, alignment: .bottom)
                    .relativeWidth(1)
                    .foregroundColor(isSelected ? Color.accentColor : Color.clear)

            }
        }
    }
}

And here's what it looks like in preview: Preview screen


Let me modestly suggest a slight modification of this bright answer: Version without using preferences:

import SwiftUI

extension HorizontalAlignment {
    private enum UnderlineLeading: AlignmentID {
        static func defaultValue(in d: ViewDimensions) -> CGFloat {
            return d[.leading]
        }
    }

    static let underlineLeading = HorizontalAlignment(UnderlineLeading.self)
}


struct GridViewHeader : View {

    @State private var activeIdx: Int = 0
    @State private var w: [CGFloat] = [0, 0, 0, 0]

    var body: some View {
        return VStack(alignment: .underlineLeading) {
            HStack {
                Text("Tweets").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 0))
                Spacer()
                Text("Tweets & Replies").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 1))
                Spacer()
                Text("Media").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 2))
                Spacer()
                Text("Likes").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 3))
                }
                .frame(height: 50)
                .padding(.horizontal, 10)
            Rectangle()
                .alignmentGuide(.underlineLeading) { d in d[.leading]  }
                .frame(width: w[activeIdx],  height: 2)
                .animation(.linear)
        }
    }
}

struct MagicStuff: ViewModifier {
    @Binding var activeIdx: Int
    @Binding var widths: [CGFloat]
    let idx: Int

    func body(content: Content) -> some View {
        var w: CGFloat = 0
        return Group {
            if activeIdx == idx {
                content.alignmentGuide(.underlineLeading) { d in
                    w = d.width
                    return d[.leading]
                }.onTapGesture { self.activeIdx = self.idx }.onAppear(perform: {self.widths[self.idx] = w})

            } else {
                content.onTapGesture { self.activeIdx = self.idx }
            }
        }
    }
}

Version using preferences and GeometryReader:

import SwiftUI

extension HorizontalAlignment {
    private enum UnderlineLeading: AlignmentID {
        static func defaultValue(in d: ViewDimensions) -> CGFloat {
            return d[.leading]
        }
    }

    static let underlineLeading = HorizontalAlignment(UnderlineLeading.self)
}

struct WidthPreferenceKey: PreferenceKey {
    static var defaultValue = CGFloat(0)

    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }

    typealias Value = CGFloat
}


struct GridViewHeader : View {

    @State private var activeIdx: Int = 0
    @State private var w: [CGFloat] = [0, 0, 0, 0]

    var body: some View {
        return VStack(alignment: .underlineLeading) {
            HStack {
                Text("Tweets")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 0, widthStorage: $w))

                Spacer()

                Text("Tweets & Replies")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 1, widthStorage: $w))

                Spacer()

                Text("Media")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 2, widthStorage: $w))

                Spacer()

                Text("Likes")
                    .modifier(MagicStuff(activeIdx: $activeIdx, idx: 3, widthStorage: $w))

                }
                .frame(height: 50)
                .padding(.horizontal, 10)
            Rectangle()
                .frame(width: w[activeIdx],  height: 2)
                .animation(.linear)
        }
    }
}

struct MagicStuff: ViewModifier {
    @Binding var activeIdx: Int
    let idx: Int
    @Binding var widthStorage: [CGFloat]

    func body(content: Content) -> some View {
        Group {

            if activeIdx == idx {
                content.background(GeometryReader { geometry in
                    return Color.clear.preference(key: WidthPreferenceKey.self, value: geometry.size.width)
                })
                .alignmentGuide(.underlineLeading) { d in
                    return d[.leading]
                }.onTapGesture { self.activeIdx = self.idx }
                    .onPreferenceChange(WidthPreferenceKey.self, perform: { self.widthStorage[self.idx] = $0 })


            } else {
                content.onTapGesture { self.activeIdx = self.idx }.onPreferenceChange(WidthPreferenceKey.self, perform: { self.widthStorage[self.idx] = $0 })
            }
        }
    }
}