Here's an interesting quandary: I want to make a timer that "ticks" reliably but, also, renders symbols in predictable places so that I could, for instance, decorate the timer by adding a background. Because of WidgetKit limitations, I cannot reliably render my own text every second and have to rely on special views, such as Text(Date(), style: .timer). However, this view can render time as, both, XX:XX and X:XX depending on how much time is left, which would be OK, except, it also, both, takes the whole width of the container and aligns to the left, which makes the last :XX move depending on time left.
Here's an illustration:
And code that produced it:
struct MyWidgetEntryView : View {
var body: some View {
VStack {
Text(Date().addingTimeInterval(1000), style: .timer)
.font(.body.monospacedDigit())
.background(Color.red)
Text(Date().addingTimeInterval(100), style: .timer)
.background(Color.green)
.font(.body.monospacedDigit())
}
}
}
Question: is there a way to make a reliably updating time display in a WidgetKit widget in such a way that symbols for minutes and seconds are always rendered in the same places and not move depending on time left?
I can't figure it out, please help me!
–Baglan
Set the multi-line text alignment:
Text(Date(), style: .timer)
.multilineTextAlignment(.trailing)
It’s not multiple lines of text but it works!
Still looking for a better answer, but here's a "proof of concept" hack to achieve my goal:
struct _T1WidgetEntryView : View {
struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
#State private var digitSize: CGSize = .zero
#State private var semicolonSize: CGSize = .zero
var body: some View {
ZStack {
Text("0")
.overlay(
GeometryReader { proxy in
Color.green
.preference(key: SizePreferenceKey.self, value: proxy.size)
}
.onPreferenceChange(SizePreferenceKey.self) { digitSize = $0 }
)
.hidden()
Text(":")
.overlay(
GeometryReader { proxy in
Color.green
.preference(key: SizePreferenceKey.self, value: proxy.size)
}
.onPreferenceChange(SizePreferenceKey.self) { semicolonSize = $0 }
)
.hidden()
Color.clear
.frame(width: digitSize.width * 4 + semicolonSize.width, height: digitSize.width * 4 + semicolonSize.width)
.overlay(
Text(Date().addingTimeInterval(100 + 3600 * 200), style: .timer)
.frame(width: digitSize.width * 7 + semicolonSize.width * 2)
,
alignment: .trailing
)
.clipped()
}
.font(.body.monospacedDigit())
}
}
And the result is:
This code assumes that all the digits are the same width (hence the .monospacedDigit() font modifier).
Here's what it does:
Calculates the sizes of a digit symbol and the semicolon;
"Normalizes" the time string by adding 200 hours to ensure the XXX:XX:XX formatting;
Sets the size of the text to accommodate strings formatted as XXX:XX:XX;
Sets the size of the container to accommodate strings formatted as XX:XX;
Aligns the text .trailing in an overlay;
Clips the whole thing to the size of the container.
Again, if there is a better solution, I'd love to learn about it!
–Baglan
Related
This question is essentially about how to define layout behaviour for a SwiftUI View such that it grows/shrinks in a particular way when given different frames externally. IE imagine you are creating a View which will be packaged up in a library and given to somebody else, without you knowing how much space they will give to your view.
The layout I would like to create will contain two horizontal views, indicated by A & B in my diagrams. I would like to control how this view expands if you specify a frame like follows:
When no frame is specified, I'd like my container View to be as small as the inner views and no bigger. See diagram 1.
When the container View is given a frame that's larger than the inner views, I'd like the space between the inner views to grow. See diagram 2.
Diagram 1: How I'd like my View to look without a frame specified.
// MyView()
| [A B] |
Diagram 2: How I'd like my View to look with a large frame.
// MyView().frame(maxWidth: .infinity)
|[A B]|
Diagram Key:
| represents my Window
[] represents my container View
A and B are my child Views.
My naive attempts:
Unmodified HStack
The behaviour of an unmodified HStack matches Diagram 1 with an unspecified frame successfully, however when given a large frame it's default behaviour is to grow as follows:
// HStack{A B}.frame(maxWidth: .infinity)
|[ AB ]|
HStack with a Spacer between the views
If I use a Stack with but add a spacer in between the views, the spacer grows to take up the most space possible, regardless of what frame is given. IE I end up with a view that looks like Diagram 2 even when no frame is specified.
// HStack{A Spacer B}
|[A B]|
I've been trying to figure out a way to tell a Spacer to prefer to be as small as possible, but to no avail. What other options do we have to achieve this layout?
Edit: To help out, here's some code as a starting point:
struct ContentView: View {
#State var largeFrame: Bool = false
var body: some View {
VStack{
Toggle("Large Frame", isOn: $largeFrame)
HStack {
Text("A")
.border(Color.red, width: 1)
Text("B")
.border(Color.red, width: 1)
}
.padding()
.frame(maxWidth: largeFrame ? .infinity : nil)
.border(Color.blue, width: 1)
}
}
}
I'm a little confused to what you are saying. Are you asking how to generate space between A and B without forcing the HStack to be window width? If so, if you place a frame on the HStack, then the spacer shoulder only separate the contents to as far as the user desires?
struct ContentView: View {
var body: some View {
HStack() {
Text("A")
Spacer()
Text("B")
}
.frame(width: 100)
}
}
EDIT:
Does the following code work? The HStack(spacing: 0) ensures that the contents the HStack have no spacing between the items and so the "smallest" possible.
struct ContentView: View {
#State private var customSpacing = true
#State private var customFrame = CGFloat(100)
var body: some View {
VStack {
Button {
customSpacing.toggle()
} label: {
Text("Custom or Not")
}
if !customSpacing {
HStack(spacing: 0) {
Text("A")
Text("B")
}
} else {
HStack(spacing: 0) {
Text("A")
Spacer()
Text("B")
}
.frame(width: customFrame)
}
}
}
}
If MyView is your component and you have control over its content, then a possible approach is to "override" .frame modifiers (all of them, below is one for demo) and compare explicitly outer width provided by frame and inner width of content subviews.
Tested with Xcode 13.4 / iOS 15.5
Main parts:
struct MyView: View { // << your component
var outerWidth: CGFloat? // << injected width !!
#State private var myWidth = CGFloat.zero // << own calculated !!
// ...
"overridden" frame modifier to store externally provided parameter
#inlinable public func frame(minWidth: CGFloat? = nil, idealWidth: CGFloat? = nil, maxWidth: CGFloat? = nil, minHeight: CGFloat? = nil, idealHeight: CGFloat? = nil, maxHeight: CGFloat? = nil, alignment: Alignment = .center) -> some View {
var newview = self
newview.outerWidth = maxWidth // << inject frame width !!
return VStack { newview } // << container to avoid cycling !!
.frame(minWidth: minWidth, idealWidth: idealWidth, maxWidth: maxWidth, minHeight: minHeight, idealHeight: idealHeight, maxHeight: maxHeight, alignment: alignment)
}
and conditionally activated space depending on width diffs
SubViewA()
.background(GeometryReader {
Color.clear.preference(key: ViewSideLengthKey.self,
value: $0.frame(in: .local).size.width)
})
if let width = outerWidth, width > myWidth { // << here !!
Spacer()
}
SubViewB()
.background(GeometryReader {
Color.clear.preference(key: ViewSideLengthKey.self,
value: $0.frame(in: .local).size.width)
})
Test module is here
Edit This is a regression in iOS 15 beta. The code works as expected on iOS 14.5:
I have submitted a bug to Apple.
I have a dashboard-style screen in my SwiftUI app, where I am using a LazyVGrid with a single .adaptative column to layout my dashboard widgets, where widgets are laid out in wrapping rows.
It works as I want it to.
However, if a widget happens to be taller than others, I would like other widgets in the same row to grow vertically, so they end up having the same height as the tallest of the row.
This small bit of code illustrates my problem:
struct ContentView: View {
var body: some View {
LazyVGrid(columns: [.init(.adaptive(minimum: 45, maximum: 50), alignment: .top)]) {
VStack {
Spacer()
Text("Hello")
}
.border(.red)
Text("Lorem ipsum")
.border(.blue)
}
.border(.green)
.padding(.horizontal, 100)
}
}
The result is:
I would like the red box (VStack containing Spacer + Hello) to be as tall as the blue box (lorem ipsum).
How could I accomplish that?
Please don't suggest using an HStack, as the above example is only to illustrate my problem with LazyVGrid. I do need to use the grid because I have quite a few children to layout, and the grid works great between phone and iPad form factors (adjusting the number of columns dynamically, exactly as I want it).
It looks like Apple begins (for unknown reason) to apply fixedSize for views in grid to make layout based on known intrinsic content sizes. The Spacer, Shape, Color, etc. do not have intrinsic size so we observe... that what's observed.
A possible approach to resolve this is perform calculations by ourselves (to find dynamically max height and apply it to all cells/views).
Here is a demo (with simple helper wrapper for cell). Tested with Xcode 13.2 / iOS 15.2
struct ContentView: View {
#State private var viewHeight = CGFloat.zero
var body: some View {
LazyVGrid(columns: [.init(.adaptive(minimum: 45, maximum: 50), alignment: .top)]) {
GridCell(height: $viewHeight) {
VStack {
Spacer()
Text("Hello")
}
}.border(.red)
GridCell(height: $viewHeight) {
Text("Lorem ipsum asdfd")
}.border(.blue)
}
.onPreferenceChange(ViewHeightKey.self) {
self.viewHeight = max($0, viewHeight)
}
.border(.green)
.padding(.horizontal, 100)
}
}
struct GridCell<Content: View>: View {
#Binding var height: CGFloat
#ViewBuilder let content: () -> Content
var body: some View {
content()
.frame(minHeight: height)
.background(GeometryReader {
Color.clear.preference(key: ViewHeightKey.self,
value: $0.frame(in: .local).size.height)
})
}
}
struct ViewHeightKey: PreferenceKey {
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value += nextValue()
}
}
I had exactly same problem. My LazyVGrid looked great on iOS 14, but now its items have different heights.
I found a dirty workaround to force the items have same height:
In my case I have only several items in each LazyVGrid (So it won't cause too much performance drop), and it is easy for me to know which item has the largest height. So I made a ZStack and put a transparent highest item behind the actual item.
struct ContentView: View {
var body: some View {
LazyVGrid(columns: [.init(.adaptive(minimum: 45, maximum: 50), alignment: .top)]) {
ZStack {
Text("Lorem ipsum") // This is the highest item.
.opacity(0) // Make it transparent.
Text("Hello")
}
.border(.red)
Text("Lorem ipsum")
.border(.blue)
}
.border(.green)
.padding(.horizontal, 100)
}
}
This workaround works in my case, but I don't recommend using it widely in your app especially when you have a lot of items in the grid.
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?
I want to use a ScrollView outside of a VStack, so that my content is scrollable if the VStack expands beyond screen size.
Now I want to use GeometryReader within the VStack and it causes problems, which I can only solve by setting the GeometryReader frame, which does not really help me given that I use the reader to define the view size.
Here is the code without a ScrollView and it works nicely:
struct MyExampleView: View {
var body: some View {
VStack {
Text("Top Label")
.background(Color.red)
GeometryReader { reader in
Text("Custom Sized Label")
.frame(width: reader.size.width, height: reader.size.width * 0.5)
.background(Color.green)
}
Text("Bottom Label")
.background(Color.blue)
}
.background(Color.yellow)
}
}
This results in the following image:
The custom sized label should be full width, but half the width for height.
Now if I wrap the same code in a ScrollView, this happens:
Not just did everything get smaller, but the height of the Custom Sized Label is somehow ignored.
If I set the height of the GeometryReader, I can adjust that behaviour, but I want to GeometryReader to grow as large as its content. How can I achieve this?
Thanks
It should be understood that GeometryReader is not a magic tool, it just reads available space in current context parent, but... ScrollView does not have own available space, it is zero, because it determines needed space from internal content... so using GeometryReader here you have got cycle - child asks parent for size, but parent expects size from child... SwiftUI renderer somehow resolves this (finding minimal known sizes), just to not crash.
Here is possible solution for your scenario - the appropriate instrument here is view preferences. Prepared & tested with Xcode 12 / iOS 14.
struct DemoLayout_Previews: PreviewProvider {
static var previews: some View {
Group {
MyExampleView()
ScrollView { MyExampleView() }
}
}
}
struct MyExampleView: View {
#State private var height = CGFloat.zero
var body: some View {
VStack {
Text("Top Label")
.background(Color.red)
Text("Custom Sized Label")
.frame(maxWidth: .infinity)
.background(GeometryReader {
// store half of current width (which is screen-wide)
// in preference
Color.clear
.preference(key: ViewHeightKey.self,
value: $0.frame(in: .local).size.width / 2.0)
})
.onPreferenceChange(ViewHeightKey.self) {
// read value from preference in state
self.height = $0
}
.frame(height: height) // apply from stored state
.background(Color.green)
Text("Bottom Label")
.background(Color.blue)
}
.background(Color.yellow)
}
}
struct ViewHeightKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
Note: ... and don't use GeometryReader if you are not sure about context in which your view is.
Is this layout possible with SwiftUI?
I want the first column to wrap the size of the labels, so in this case it will be just big enough to show "Bigger Label:". Then give the rest of the space to the second column.
This layout is pretty simple with auto layout.
SwiftUI 2020 has LazyVGrid but the only ways I see to set the column sizes use hardcoded numbers. Do they not understand what a problem that causes with multiple languages and user-adjustable font sizes?
It is not so complex if to compare number of code lines to make this programmatically in both worlds...
Anyway, sure it is possible. Here is a solution based on some help modifier using view preferences feature. No hard. No grid.
Demo prepared & tested with Xcode 12 / iOS 14.
struct DemoView: View {
#State private var width = CGFloat.zero
var body: some View {
VStack {
HStack {
Text("Label1")
.alignedView(width: $width)
TextField("", text: .constant("")).border(Color.black)
}
HStack {
Text("Bigger Label")
.alignedView(width: $width)
TextField("", text: .constant("")).border(Color.black)
}
}
}
}
and helpers
extension View {
func alignedView(width: Binding<CGFloat>) -> some View {
self.modifier(AlignedWidthView(width: width))
}
}
struct AlignedWidthView: ViewModifier {
#Binding var width: CGFloat
func body(content: Content) -> some View {
content
.background(GeometryReader {
Color.clear
.preference(key: ViewWidthKey.self, value: $0.frame(in: .local).size.width)
})
.onPreferenceChange(ViewWidthKey.self) {
if $0 > self.width {
self.width = $0
}
}
.frame(minWidth: width, alignment: .trailing)
}
}