iosswiftswiftuimultiple-columnssizing

How to have 1 column in a multiple column list be of the same width w/out using a frame modifier of width so to retain flexibility


I have a list of entries that consist of multiple columns of UI with all except the first free to be uniquely sized horizontally (i.e. they’re as short/long as their content demands). I know with the first consistently sized column I can set a frame modifier width to achieve this, but I was hoping there is a better and more flexible way to get the desired behaviour. The reason being I don’t believe the solution is optimised to consider the user’s display size nor the actual max content width of the columns. That is, the width set will either not be wide enough when the display size is set to the largest, or, if it is, then it will be unnecessarily wide on a smaller/regular display size.

This is my current best attempt:

        GeometryReader { geometry in
            
        VStack {
        
            HStack {
                HStack {
                    Text("9am")
                    
                    Image(systemName: "cloud.drizzle").font(Font.title2)
                        .offset(y: 4)
                }.padding(.all)
                .background(Color.blue.opacity(0.2))
                .cornerRadius(16)
                            
                VStack {
                    HStack {
                        Text("Summary")
                            .padding(.trailing, 4)
                            .background(Color.white)
                            .layoutPriority(1)
                        VStack {
                            Spacer()
                            Divider()
                            Spacer()
                        }
                        VStack {
                            Text("12°")
                            Text("25%")
                                .foregroundColor(Color.black)
                                .background(Color.white)
                        }.offset(y: -6)
                        Spacer()
                    }.frame(width: geometry.size.width/1.5)
                }
                
                Spacer()
            }
            
            HStack {
                HStack {
                    Text("10am")
                        .customFont(.subheadline)
                    
                    Image(systemName: "cloud.drizzle").font(Font.title2)
                        .offset(y: 4)
                        .opacity(0)
                }
                .padding(.horizontal)
                .padding(.vertical,4)
                .background(Color.blue.opacity(0.2))
                .cornerRadius(16)
                            
                VStack {
                    HStack {
                        ZStack {
                            Text("Mostly cloudy")
                                .customFont(.body)
                                .padding(.trailing, 4)
                                .background(Color.white)
                                .opacity(0)
                            VStack {
                                Spacer()
                                Divider()
                                Spacer()
                            }
                        }
                        VStack {
                            Text("13°")
                            Text("25%")
                                .foregroundColor(Color.black)
                                .background(Color.white)
                        }.offset(y: -6)
                        Spacer()
                    }.frame(width: geometry.size.width/1.75)
                }
                
                Spacer()
            
            } 
      } 
}

For me, this looks like:

enter image description here

As you can tell, 10 am is slightly wider than 9 am. To keep them as closely sized as possible, I’m including a cloud icon in it too, albeit with zero opacity. Ideally, 10 am would be sized the same as 9 am without needing a transparent cloud icon. More generally speaking, what would make sense is the widest HStack in this column is identified and then whatever its width is will be applied to all other columns. Keep in mind, my code above is static for demo purposes. It will be a view that is rendered iterating through a collection of rows.


Solution

  • Guided by https://www.wooji-juice.com/blog/stupid-swiftui-tricks-equal-sizes.html, I accomplished this.

    This is the piece of UI I want to make sure is horizontally sized equally across all rows with the width set to whatever is the highest:

                HStack {
                    VStack {
                        Spacer()
                        Text("9am")
                        Spacer()
                    }
                }.frame(minWidth: self.maximumSubViewWidth)
                .overlay(DetermineWidth())
    

    The stack the above is contained in has an OnPreferenceChange modifier:

               .onPreferenceChange(DetermineWidth.Key.self) {
                    if $0 > maximumSubViewWidth {
                        maximumSubViewWidth = $0
                    }
                }
    

    The magic happens here:

    struct MaximumWidthPreferenceKey: PreferenceKey
    {
        static var defaultValue: CGFloat = 0
        static func reduce(value: inout CGFloat, nextValue: () -> CGFloat)
        {
            value = max(value, nextValue())
        }
    }
    
    struct DetermineWidth: View
    {
        typealias Key = MaximumWidthPreferenceKey
        var body: some View
        {
            GeometryReader
            {
                proxy in
                Color.clear
                    .anchorPreference(key: Key.self, value: .bounds)
                    {
                        anchor in proxy[anchor].size.width
                    }
            }
        }
    }
    

    The link at the top best describes each’s purpose.

    MaximumWidthPreferenceKey

    This defines a new key, sets the default to zero, and as new values get added, takes the widest

    DetermineWidth

    This view is just an empty (Color.clear) background, but with our new preference set to its width. We’ll get back to that clear background part in a moment, but first: there are several ways to set preferences, here, we’re using anchorPreference. Why?

    Well, anchorPreference has “No Overview Available” so I don’t actually have a good answer for that, other than it seems to be more reliable in practice. Yeah, cargo-cult code. Whee! I have a hunch that, what with it taking a block and all, SwiftUI can re-run that block to get an updated value when there are changes that affect layout.

    Another hope I have is that this stuff will get better documented, so that we can better understand how these different types are intended to be used and new SwiftUI developers can get on board without spending all their time on Stack Overflow or reading blog posts like this one.

    Anyway, an anchor is a token that represents a dimension or location in a view, but it doesn’t give you the value directly, you have to cash it in with a GeometryProxy to get the actual value, so, that’s what we did — to get the value, you subscript a proxy with it, so proxy[anchor].size.width gets us what we want, when anchor is .bounds (which is the value we passed in to the anchorPreference call). It’s kind of twisted, but it gets the job done.

    maximumSubViewWidth is a binding variable passed in from the parent view to ensure the maximumSubViewWidth each subview refers to is always the the up-to-date maximum.

                ForEach(self.items) { item, in
                    ItemSubview(maximumSubViewWidth: $maximumSubViewWidth, item: item)
                }
    

    The one issue with this was there was an undesired subtle but still noticeable animation on the entire row with any UI that gets resized to the max width. What I did to work around this is add an animation modifier to the parent container that’s nil to start with that switches back to .default after an explicit trigger.

    .animation(self.initialised ? .default : nil)
    

    I set self.initialised to be true after the user explicitly interacts with the row (In my case, they tap on a row to expand to show additional info) – this ensured the initial animation doesn't incorrectly happen but animations go back to normal after that. My original attempt toggled initialised's state in the .onAppear modifier so that the change is automatic but that didn't work because I’m assuming resizing can occur after the initial appearance.

    The other thing to note (which possibly suggests although this solution works that it isn't the best method) is I'm seeing this message in the console repeated for either every item, or just the ones that needed to be resized (unclear but the total number of warnings = number of items):

    Bound preference MaximumWidthPreferenceKey tried to update multiple times per frame.

    If anyone can think of a way to achieve the above whilst avoiding this warning then great!

    UPDATE: I figured the above out.

    It’s actually an important change because without addressing this I was seeing the column keep getting wider on subsequent visits to the screen.

    The view has a new widthDetermined @State variable that’s set to false, and becomes true inside .onAppeared.

    I then only determine the width for the view IF widthDetermined is false i.e. not set. I do this by using the conditional modifier proposed at https://fivestars.blog/swiftui/conditional-modifiers.html:

        func `if`<Content: View>(_ conditional: Bool, content: (Self) -> Content) -> TupleView<(Self?, Content?)> {
        if conditional { return TupleView((nil, content(self))) }
        else { return TupleView((self, nil)) }
    }
    

    and in the view:

                .if(!self.widthDetermined) {
                    $0.overlay(DetermineWidth())
                }