solidcell
solidcell

Reputation: 7729

How can a SwiftUI View adjust its ideal size in one dimension when the other dimension is clamped?

How can a View adjust its ideal size in one dimension when the other dimension is clamped?

For instance, Text exhibits this behavior. If constrained via .fixedSize and given too little space in one dimension, then the ideal size for the other dimension will grow in response, if possible.

Using .fixedSize(horizontal: false, vertical: false)

Using .fixedSize(horizontal: false, vertical: true)

Using .fixedSize(horizontal: true, vertical: false)

It looks like Text has somehow defined its ideal size as something like .frame(idealWidth: 400, idealHeight: 20) in the first two cases. However, the part that's confusing me is that it seems to be modifying that in the third case to something like .frame(idealWidth: 80, idealHeight: 100).

How can we, like Text, allow our View to update its ideal size in response to a dimension(s) being clamped?

Here's a bit of code that shows this behavior in Text which I described above:

VStack(spacing: 100) {
    Group {
        Text("ooooooooooooooooooooooooooooooooooo")
            .fixedSize(horizontal: false, vertical: false)
        Text("ooooooooooooooooooooooooooooooooooo")
            .fixedSize(horizontal: true, vertical: false)
        Text("ooooooooooooooooooooooooooooooooooo")
            .fixedSize(horizontal: false, vertical: true)
    }
    .background(Color.green)
    .frame(width: 75, height: 75)
    .background(Color.blue)
}

With this font and repeating "o" content, Text wants to basically have square space equal to about 7,000 points (338x20 ideal size for case #2 and 68x108 for case #3).

How could this be replicated in a custom View? The minHeight and minWidth are not static; They depend on the parent having clamped a dimension or not. How can we, as the child, know which dimensions we've been clamped in, if any? The proposed size from the parent is still 75x75 in each example, so what is it that the child view has access to with which to base its ideal size off of? How is Text doing this?

To make an answer more concrete, consider how you could replace the Text("oooooo") instances with instances of a custom class which behaves similarly with regard to .fixedSize. ie. attempt to maintain a 7,000 point square area, with a minimum 20 point height, preferring to grow horizontally first.

Upvotes: 1

Views: 3576

Answers (1)

zrzka
zrzka

Reputation: 21219

It's problematic due to the nature how the fixedSize works:

  • We can provide min, ideal and max values for the width & height where min <= ideal <= max.
  • We have to provide them upfront which is impossible because the ideal values will differ for horizontal/vertical fix.

GeometryReader wrapper

enter image description here

The commented code is self explanatory.

import SwiftUI

extension View {
    func frame(size: CGSize) -> some View {
        self.frame(width: size.width, height: size.height)
    }
}

struct TextLike: View {
    // It's like text, how much space we'd like to occupy
    var squareArea: CGFloat

    // Text() draws at least one line of text, we'll do the same
    private let preferredHeight: CGFloat = 20

    // Preferred/Max width in case of one line of text
    private let preferredWidth: CGFloat

    init(squareArea: CGFloat) {
        self.squareArea = squareArea
        self.preferredWidth = squareArea / preferredHeight
    }

    var body: some View {
        GeometryReader { proxy in
            // Let's say that this is the text
            Color.green.opacity(0.5)
                .frame(size: self.size(withProxy: proxy))
        }
        .frame(
            // Ideally we'd like to draw it as one line of text
            idealWidth: preferredWidth,
            // Maximum width equals to ideal width (one line of text)
            maxWidth: preferredWidth,
            // At least one line of text
            minHeight: preferredHeight,
            // Ideally we'd like to draw it as one line of text
            idealHeight: preferredHeight
        )
    }

    private func size(withProxy proxy: GeometryProxy) -> CGSize {
        //
        // Good enough for the demonstration, but you should get PhD & read:
        //
        //   https://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html
        //   https://stackoverflow.com/a/10335601/581190
        //   https://stackoverflow.com/a/10334868/581190
        //
        // Also it must be generic enough to cover different devices, ...
        //
        //   proxy.size.width on iPhone SE (2nd) = 350.0
        //   proxy.size.width on iPhone 11 Pro Max = 350.3333...
        //
        // Just be aware of this and provide proper logic.
        //
        let horizontalFix = abs(proxy.size.width.distance(to: preferredWidth)).isLessThanOrEqualTo(1.0)
        let verticalFix = abs(proxy.size.height.distance(to: preferredHeight)).isLessThanOrEqualTo(1.0)

        switch (horizontalFix, verticalFix) {
        case (true, _):
            // Horizontal fix -> use preferred (= max) width -> one line of text
            // Vertical is irrelevant, because we do prefer horizontal grow
            return CGSize(width: preferredWidth, height: preferredHeight)
        case (false, false):
            // Use the offered size -> fits container, possible truncation
            return proxy.size
        case (false, true):
            // Vertical fix -> use offered width & calculate height
            return CGSize(width: proxy.size.width, height: squareArea / proxy.size.width)
        }
    }
}

struct ContentView: View {
    var body: some View {
        VStack(spacing: 50) {
            Group {
                TextLike(squareArea: 7000)
                    .fixedSize(horizontal: false, vertical: false)
                TextLike(squareArea: 7000)
                    .fixedSize(horizontal: true, vertical: false)
                TextLike(squareArea: 7000)
                    .fixedSize(horizontal: false, vertical: true)
                TextLike(squareArea: 7000)
                    .fixedSize(horizontal: true, vertical: true)
            }
            .frame(width: 75, height: 75)
            .background(Color.blue)
        }
    }
}

Problems

  • TextLike acts as a wrapper and the actual view is in it (one additional layer).
  • We can't use background modifier on it for example.

Let's modify the code and use the background modifier on the TextLike level like you did.

struct ContentView: View {
    var body: some View {
        VStack(spacing: 50) {
            Group {
                TextLike(squareArea: 7000)
                    .fixedSize(horizontal: false, vertical: false)
                TextLike(squareArea: 7000)
                    .fixedSize(horizontal: true, vertical: false)
                TextLike(squareArea: 7000)
                    .fixedSize(horizontal: false, vertical: true)
                TextLike(squareArea: 7000)
                    .fixedSize(horizontal: true, vertical: true)
            }
            .background(Color.red.opacity(0.2))
            .frame(width: 75, height: 75)
            .background(Color.blue)
        }
    }
}

This is what happens:

enter image description here

Frankly speaking, not ideal & nice, but closest I can get for now.

Upvotes: 2

Related Questions