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() } } } }
To make a SwiftUI view take all available width, we use . frame() modifier with maxWidth and maxHeight set to . infinity . The result of using .
By default SwiftUI's views take up only as much space as they need, but if you want that to change you can use a frame() modifier to tell SwiftUI what kind of size range you want to have.
SwiftUI makes it easy to create two views that are the same size, regardless of whether you want the same height or the same width, by combining a frame() modifier with fixedSize() – there's no need for a GeometryReader or similar.
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:
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
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 } } } } }
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:
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.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) } } } } }
underline
view is is a 2 point high Rectangle
, put in an .overlay()
on top of the HStack
.underline
view is aligned to .bottomLeading
, so that we can programmatically set its .padding(.leading, _)
using a @State
value..frame(width:)
is also set using a @State
value.HStack
is set as the .coordinateSpace(name: "container")
so we can find the frame of our buttons relative to this.MoveUnderlineButton
uses a GeometryReader
to find its own width
and minX
in order to set the respective values for the underline
viewMoveUnderlineButton
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. If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With