SwiftUI - How to get a grid column to shrink to content width? - swiftui

I'm messing about with a grid where I want the first column to shrink to the width of the content and the second column to fill the remaining space. Using IB and layout constraints this sort of thing is relatively simple, but it's got me stumped in SwiftUI.
Basically I have code like this:
#State var sliderValue1: Float = 0.5
#State var sliderValue2: Float = 0.25
var body: some View {
let col1 = GridItem(alignment: .leading)
let col2 = GridItem()
LazyVGrid(columns: [col1, col2]) {
Text("Short").border(Color.green)
Slider(value: $sliderValue1, in: 0.0 ... 1.0, step: 0.25).border(Color.red)
Text("Rather long").border(Color.green)
Slider(value: $sliderValue2, in: 0.0 ... 1.0, step: 0.25).border(Color.red)
}
.border(Color.blue)
}
As you can see the two columns are the same size which is not what I want. What I'm trying to do is shrink the first column's width to that of the "Rather long" label. I can't specify a size using .fixed(...) and I've tried all sorts of .flexible(...) definitions with no luck.
Anyone got a column to resize to fit the content?

Thanks to #asperi I dug around some more in the mentioned posts and came up with the following code. Basically I ended up throwing out the grid because I found that even with preference keys passing values back up the hierarchy, the grid was setting the width and not allow the labels to calculate the sizes I needed. So vertical and horizontal stacks were the answer, allowing things to size correctly.
// The preference key used to advise parent views of a change in value.
struct LabelWidthPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = .zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
print("Reducing \(value) \(nextValue())")
value = max(value, nextValue())
}
}
// A modifier that wraps a view in a geometry reader.
// This allows us to measure the size of a view and send that back up the view heirarchy
// so the display can be sized to fit.
struct LabelWidthObserver: ViewModifier {
func body(content: Content) -> some View {
return content.background {
GeometryReader { geometry in
background(geometry: geometry)
}
}
}
private func background(geometry: GeometryProxy) -> some View {
Color.clear.preference(key: LabelWidthPreferenceKey.self, value: geometry.size.width)
}
}
struct ContentView: View {
#State private var sliderValue1: Float = 0.5
#State private var sliderValue2: Float = 0.25
#State private var labelWidth: CGFloat = 20.0
var body: some View {
VStack {
aspect(withTitle: "Short")
aspect(withTitle: "Rather long")
}
.border(Color.blue).padding(5)
.onPreferenceChange(LabelWidthPreferenceKey.self) {
print("Incoming label width \($0)")
labelWidth = max(labelWidth, $0)
}
}
#ViewBuilder
func aspect(withTitle title: String) -> some View {
HStack {
Text(title)
.border(Color.green)
.frame(minWidth: labelWidth, alignment: .leading)
.modifier(LabelWidthObserver())
Slider(value: $sliderValue1, in: 0.0 ... 1.0, step: 0.25).border(Color.red)
}
.border(.yellow).padding(5)
}
}
Which produces:
In which you can see that the labels and sliders are now acting like they are in columns even though they are not.

Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10) {
GridRow() {
Text("Short")
.background {
Color.yellow
}
Slider(value: $sliderValue1, in: 0.0 ... 1.0, step: 0.25).border(Color.red)
.frame(maxWidth: .infinity)
.background {
Color.blue
}
}
GridRow() {
Text("Rather long")
.background {
Color.yellow
}
Slider(value: $sliderValue2, in: 0.0 ... 1.0, step: 0.25).border(Color.red)
.frame(maxWidth: .infinity)
.background {
Color.blue
}
}
}
.frame(maxWidth: .infinity)
.background {
Color.orange
}

Related

How to dynamically get view's origin in swiftui

Background
Using SwiftUI, I want to implement tutorial(Coach marks) functionalities for some view( like button, text, image and so on) which was defined without specifying origin and sizes
for modifier frame. especially such functionalities will be asked for several difference screen.
My Test Code
1. Definition of PreferenceKey
struct MyPreferenceKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
main view
struct ContentView: View {
#State var rating : Int = 3
var body: some View {
GeometryReader { geometry in
let origin = geometry.frame(in: .global).origin
let size = geometry.frame(in: .global).size
VStack {
HStack{
Text("Test1") // for testing following target,just add some view hierachy
.padding()
.background(Color.yellow)
VStack{
Text("test2")
.padding()
.background(Color.purple)
Text("") // here is my target, i want to get the origin of this view
.frame(width: 100, height: 200) // just for having spece to show value of frame
.background(Color.green)
.overlay(
GeometryReader { proxy in
let frm = proxy.frame(in: .global)
let size = proxy.size
Color.clear.preference(key: MyPreferenceKey.self,value: proxy.frame(in: .global))
Text(verbatim: "X=\(String(format: "%.2f", frm.midX)) y=\(String(format: "%.2f", frm.midY) ) width=\(String(format: "%.2f", frm.width)) height=\(String(format: "%.2f", frm.height))")
}
)
}
}
.onPreferenceChange(MyPreferenceKey.self){
print("\($0)")
}
}
.frame(width: 200, height: 300, alignment: .center)
}
}
}
3. Ran result (Xcode)
4.Ran result (Log)
(89.83333333333331, 127.16666666666669, 100.0, 200.0)
5. Problem
the view with green background says it's origin.x is 139, but logs printed above is 89 in modifier onPreferenceChange.
My Question:
how can i get real origin.x with 139 using like modifier onPreferenceChange.
is there a way to get value shown by above Text view
thanks.

How do I make two Text() views the same dynamic font size in SwiftUI, maximizing the font size?

Reacting to the user’s device rotation and taking into account the various screen sizes of iPhones and iPads i want two (for example) Text() Views to have the maximum possible font size without truncating and without line wrapping. I tried a lot, lastly this and nothing worked.
struct MinimumHeightPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 1_000.0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = min(value, nextValue())
}
}
struct DetermineHeight: View{
typealias Key = MinimumHeightPreferenceKey
var body: some View {
GeometryReader { proxy in
Color.clear
.anchorPreference(key: Key.self, value: .bounds) {
anchor in proxy[anchor].size.height
}
}
}
}
struct ContentView: View {
#State var minTextHeight: CGFloat = 75
var body: some View {
VStack {
HStack(alignment: VerticalAlignment.firstTextBaseline){
Text("Short Text")
.frame(maxHeight: minTextHeight)
.overlay(DetermineHeight())
.border(Color.red)
.scaledToFit()
Text("This is a considerably longer text. ideally it should also reduce the shorter Text's size so they both look the same.")
//.frame(minHeight: 1.0, maxHeight: minTextHeight)
.overlay(DetermineHeight())
.border(Color.green)
}
.font(.title)
.lineLimit(1)
.minimumScaleFactor(0.25)
.onPreferenceChange(DetermineHeight.Key.self) {
minTextHeight = $0
}
Text("minHeight = \(minTextHeight)")
}
}
}
The boxes turn out the same hight, but the texts aren’t adjusted. (Plus: if I uncomment that line I the boxes don’t resize at all. Huh?)
Am I trying to do the impossible?

GeometryReader inside ScrollView - scroll doesn't work anymore [duplicate]

With the new ScrollViewReader, it seems possible to set the scroll offset programmatically.
But I was wondering if it is also possible to get the current scroll position?
It seems like the ScrollViewProxy only comes with the scrollTo method, allowing us to set the offset.
Thanks!
It was possible to read it and before. Here is a solution based on view preferences.
struct DemoScrollViewOffsetView: View {
#State private var offset = CGFloat.zero
var body: some View {
ScrollView {
VStack {
ForEach(0..<100) { i in
Text("Item \(i)").padding()
}
}.background(GeometryReader {
Color.clear.preference(key: ViewOffsetKey.self,
value: -$0.frame(in: .named("scroll")).origin.y)
})
.onPreferenceChange(ViewOffsetKey.self) { print("offset >> \($0)") }
}.coordinateSpace(name: "scroll")
}
}
struct ViewOffsetKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
I found a version without using PreferenceKey. The idea is simple - by returning Color from GeometryReader, we can set scrollOffset directly inside background modifier.
struct DemoScrollViewOffsetView: View {
#State private var offset = CGFloat.zero
var body: some View {
ScrollView {
VStack {
ForEach(0..<100) { i in
Text("Item \(i)").padding()
}
}.background(GeometryReader { proxy -> Color in
DispatchQueue.main.async {
offset = -proxy.frame(in: .named("scroll")).origin.y
}
return Color.clear
})
}.coordinateSpace(name: "scroll")
}
}
I had a similar need but with List instead of ScrollView, and wanted to know wether items in the lists are visible or not (List preloads views not yet visible, so onAppear()/onDisappear() are not suitable).
After a bit of "beautification" I ended up with this usage:
struct ContentView: View {
var body: some View {
GeometryReader { geometry in
List(0..<100) { i in
Text("Item \(i)")
.onItemFrameChanged(listGeometry: geometry) { (frame: CGRect?) in
print("rect of item \(i): \(String(describing: frame)))")
}
}
.trackListFrame()
}
}
}
which is backed by this Swift package: https://github.com/Ceylo/ListItemTracking
The most popular answer (#Asperi's) has a limitation:
The scroll offset can be used in a function
.onPreferenceChange(ViewOffsetKey.self) { print("offset >> \($0)") }
which is convenient for triggering an event based on that offset.
But what if the content of the ScrollView depends on this offset (for example if it has to display it). So we need this function to update a #State.
The problem then is that each time this offset changes, the #State is updated and the body is re-evaluated. This causes a slow display.
We could instead wrap the content of the ScrollView directly in the GeometryReader so that this content can depend on its position directly (without using a State or even a PreferenceKey).
GeometryReader { geometry in
content(geometry.frame(in: .named(spaceName)).origin)
}
where content is (CGPoint) -> some View
We could take advantage of this to observe when the offset stops being updated, and reproduce the didEndDragging behavior of UIScrollView
GeometryReader { geometry in
content(geometry.frame(in: .named(spaceName)).origin)
.onChange(of: geometry.frame(in: .named(spaceName)).origin,
perform: offsetObserver.send)
.onReceive(offsetObserver.debounce(for: 0.2,
scheduler: DispatchQueue.main),
perform: didEndScrolling)
}
where offsetObserver = PassthroughSubject<CGPoint, Never>()
In the end, this gives :
struct _ScrollViewWithOffset<Content: View>: View {
private let axis: Axis.Set
private let content: (CGPoint) -> Content
private let didEndScrolling: (CGPoint) -> Void
private let offsetObserver = PassthroughSubject<CGPoint, Never>()
private let spaceName = "scrollView"
init(axis: Axis.Set = .vertical,
content: #escaping (CGPoint) -> Content,
didEndScrolling: #escaping (CGPoint) -> Void = { _ in }) {
self.axis = axis
self.content = content
self.didEndScrolling = didEndScrolling
}
var body: some View {
ScrollView(axis) {
GeometryReader { geometry in
content(geometry.frame(in: .named(spaceName)).origin)
.onChange(of: geometry.frame(in: .named(spaceName)).origin, perform: offsetObserver.send)
.onReceive(offsetObserver.debounce(for: 0.2, scheduler: DispatchQueue.main), perform: didEndScrolling)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.coordinateSpace(name: spaceName)
}
}
Note: the only problem I see is that the GeometryReader takes all the available width and height. This is not always desirable (especially for a horizontal ScrollView). One must then determine the size of the content to reflect it on the ScrollView.
struct ScrollViewWithOffset<Content: View>: View {
#State private var height: CGFloat?
#State private var width: CGFloat?
let axis: Axis.Set
let content: (CGPoint) -> Content
let didEndScrolling: (CGPoint) -> Void
var body: some View {
_ScrollViewWithOffset(axis: axis) { offset in
content(offset)
.fixedSize()
.overlay(GeometryReader { geo in
Color.clear
.onAppear {
height = geo.size.height
width = geo.size.width
}
})
} didEndScrolling: {
didEndScrolling($0)
}
.frame(width: axis == .vertical ? width : nil,
height: axis == .horizontal ? height : nil)
}
}
This will work in most cases (unless the content size changes, which I don't think is desirable). And finally you can use it like that :
struct ScrollViewWithOffsetForPreviews: View {
#State private var cpt = 0
let axis: Axis.Set
var body: some View {
NavigationView {
ScrollViewWithOffset(axis: axis) { offset in
VStack {
Color.pink
.frame(width: 100, height: 100)
Text(offset.x.description)
Text(offset.y.description)
Text(cpt.description)
}
} didEndScrolling: { _ in
cpt += 1
}
.background(Color.mint)
.navigationTitle(axis == .vertical ? "Vertical" : "Horizontal")
}
}
}

Content hugging priority behaviour in SwiftUI

I have a List made of cells, each containing an image, and a column of text, which I wish laid out in a specific way. Image on the left, taking up a quarter of the width. The rest of the space given to the text, which is left-aligned.
Here's the code I got:
struct TestCell: View {
let model: ModelStruct
var body: some View {
HStack {
Image("flag")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: UIScreen.main.bounds.size.width * 0.25)
VStack(alignment: .leading, spacing: 5) {
Text("Country: Moldova")
Text("Capital: Chișinău")
Text("Currency: Leu")
}
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
}
}
}
struct TestCell_Previews: PreviewProvider {
static var previews: some View {
TestCell()
.previewLayout(.sizeThatFits)
.previewDevice("iPhone 11")
}
}
And here are 2 examples:
As you can see, the height of the whole cell varies based on the aspect ratio of the image.
$1M question - How can we make the cell height hug the text (like in the second image) and not vary, but rather shrink the image in a scaleAspectFit manner inside the allocated rectangle
Note!
The text's height can vary, so no hardcoding.
Couldn't make it work with PreferenceKeys, as the cells will be part of a List, and there's some peculiar behaviour I'm trying to grasp around cell reusage, and onPreferenceChange not being called when 2 consecutive cells have the same height. To exhibit all this combined behaviour, make sure your model varies between cells when you test it.
Here is a possible solution, however it uses GeometryReader inside the background property of the VStack, to detect their height. That height is being applied to the Image then. I used SizePreferenceKey from this solution.
struct SizePreferenceKey: PreferenceKey {
typealias Value = CGSize
static var defaultValue: Value = .zero
static func reduce(value _: inout Value, nextValue: () -> Value) {
_ = nextValue()
}
}
struct ContentView6: View {
#State var childSize: CGSize = .zero
var body: some View {
HStack {
Image("image1")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: UIScreen.main.bounds.size.width * 0.25, height: self.childSize.height)
VStack(alignment: .leading, spacing: 5) {
Text("Country: Moldova")
Text("Capital: Chișinău")
Text("Currency: Leu")
}
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.background(
GeometryReader { proxy in
Color.clear.preference(key: SizePreferenceKey.self, value: proxy.size)
}
)
}
.onPreferenceChange(SizePreferenceKey.self) { preferences in
self.childSize = preferences
}
.border(Color.yellow)
}
}
Will look like this.. you can apply different aspect ratios for the Image of course.
This is what worked for me to constrain a color view to the height of text content in a cell:
A height reader view:
struct HeightReader: View {
#Binding var height: CGFloat
var body: some View {
GeometryReader { proxy -> Color in
update(with: proxy.size.height)
return Color.clear
}
}
private func update(with value: CGFloat) {
guard value != height else { return }
DispatchQueue.main.async {
height = value
}
}
}
You can then use the reader in a compound view as a background on the view you wish to constrain to, using a state object to update the frame of the view you wish to constrain:
struct CompoundView: View {
#State var height: CGFloat = 0
var body: some View {
HStack(alignment: .top) {
Color.red
.frame(width: 2, height: height)
VStack(alignment: .leading) {
Text("Some text")
Text("Some more text")
}
.background(HeightReader(height: $height))
}
}
}
struct CompoundView_Previews: PreviewProvider {
static var previews: some View {
CompoundView()
}
}
I have found that using DispatchQueue to update the binding is important.

How to use GeometryReader to achieve a table like layout?

I'm trying to achieve a layout like this:
For this simple example the base would be something like this:
HStack {
VStack {
Text("Foo")
Text("W")
Text("X")
}
VStack {
Text("Bar")
Text("Y")
Text("Z")
}
}
Now that relativeSize(...) is deprecated, the only remaining option I see is GeometryReader, but the issue with it is that once it's itself nested in another stack, it will attempt to fill all available space, in other terms it cannot determine the size it's containing stack would have had if it wasn't present in it and I end up with an overly sized stack.
I wonder if I'm missing something or if this is just how stacks work, or maybe a beta bug?
Thank you for your help
EDIT:
I did this:
VStack {
GeometryReader { /* #kontiki code */ }
Text("Other")
Spacer().layoutPriority(1)
}
But unfortunately this is the result I get, do you think this is a SwiftUI bug?
Second Attempt
I think this does exactly what you need. It uses Preferences. If you need to learn more about how to use SwiftUI preferences, check this post I wrote. They are fully explained there, but it is too long of a subject to post it here.
import SwiftUI
struct MyPref: PreferenceKey {
typealias Value = CGFloat
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
struct SetWidthPreference: View {
var body: some View {
GeometryReader { proxy in
Rectangle().fill(Color.clear).preference(key: MyPref.self, value: proxy.size.width)
}
}
}
struct ContentView : View {
#State private var width: CGFloat = 0
var body: some View {
VStack {
ScrollView {
HStack(spacing: 0) {
VStack {
Text("Foo")
Text("Bar")
}.frame(width: width * 0.7, alignment: .leading).fixedSize().border(Color.red)
VStack {
Text("W")
Text("Y")
}.frame(width: width * 0.15).fixedSize().border(Color.red)
VStack {
Text("X")
Text("Z")
}.frame(width: width * 0.15).fixedSize().border(Color.red)
}
Text("Text below table")
}
.border(Color.green, width: 3)
HStack { Spacer() }.background(SetWidthPreference())
}
.onPreferenceChange(MyPref.self) { w in
print("\(w)")
DispatchQueue.main.async {
self.width = w
}
}
}
}
Previous Attempt (I keep it here, so comments make sense)
This example will draw 3 columns with 0.7, 0.15 and 0.15 of the parent's width. It's a starting point that you can fine tune. Note that the borders are there so that you can see what you are doing, of course you can remove them.
If GeometryReader is expanding too much, explain exactly what is that you want to accomplish, providing more context on the surroundings of the table (i.e., GeometryReader).
struct ContentView : View {
var body: some View {
GeometryReader { proxy in
HStack(spacing: 0) {
VStack {
Text("Foo")
Text("Bar")
}.frame(width: proxy.size.width * 0.7, alignment: .leading).fixedSize().border(Color.red)
VStack {
Text("W")
Text("Y")
}.frame(width: proxy.size.width * 0.15).fixedSize().border(Color.red)
VStack {
Text("X")
Text("Z")
}.frame(width: proxy.size.width * 0.15).fixedSize().border(Color.red)
}
}.padding(20)
}
}