Size a SwiftUI view to be safeAreaInsets.top + 'x' - swiftui

I am trying to create a full bleed SwiftUI 'header' view in my scene. This header will sit within a List or a scrollable VStack.
In order to do this, I'd like to have my text in the header positioned below the safe area, but the full view should extend from the top of the screen (and thus, overlap the safe area). Here is visual representation:
V:[(safe-area-spacing)-(padding)-(text)]
here is my attempt:
struct HeaderView: View {
#State var spacing: CGFloat = 100
var body: some View {
HStack {
VStack(alignment: .leading) {
Rectangle()
.frame(height: spacing)
.opacity(0.5)
Text("this!").font(.largeTitle)
Text("this!").font(.headline)
Text("that!").font(.subheadline)
}
Spacer()
}
.frame(maxWidth: .infinity)
.background(Color.red)
.background(
GeometryReader { proxy in
Color.clear
.preference(
key: SafeAreaSpacingKey.self,
value: proxy.safeAreaInsets.top
)
}
)
.onPreferenceChange(SafeAreaSpacingKey.self) { value in
self.spacing = value
}
}
}
This however, does not seem to correctly size 'Rectangle'. How can I size a view according to the safe area?

Is this what you're looking for? I try to avoid using GeometryReader unless you really need it... I created a MainView, which has a background and a foreground layer. The background layer will ignore the safe areas (full bleed) but the foreground will stay within the safe area by default.
struct HeaderView: View {
var body: some View {
HStack {
VStack(alignment: .leading) {
Text("this!").font(.largeTitle)
Text("this!").font(.headline)
Text("that!").font(.subheadline)
}
Spacer(minLength: 0)
}
}
}
struct MainView: View {
var body: some View {
ZStack {
// Background
ZStack {
}
.frame(maxWidth:. infinity, maxHeight: .infinity)
.background(Color.red)
.edgesIgnoringSafeArea(.all)
// Foreground
VStack {
HeaderView()
Spacer()
}
}
}
}

add an state to store desired height
#State desiredHeight : CGFloat = 0
then on views body :
.onAppear(perform: {
if let window = UIApplication.shared.windows.first{
let phoneSafeAreaTopnInset = window.safeAreaInsets.top
desiredHeight = phoneSafeAreaTopnInset + x
}
})
set the desiredHeight for your view .
.frame(height : desiredHeight)

Related

Sticky section in SwiftUI scrollview

I'm trying to simulate sticky section header that PlainListStyle has in scrollView scenario:
struct MyScreen: View {
#State private var scrollViewOffset: CGFloat = .zero
#State var headerScrollView: CGRect = .zero
#State var headerTop: CGRect = .zero
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Static top content")
.frame(width: UIScreen.main.bounds.width, height: 50)
.background(Color.red)
Spacer()
}
ZStack(alignment: .top) {
scrollable
tabHeader
.background(Color.white)
.opacity(shouldSwap ? 1 : 0)
.rectReader($headerTop, in: .global)
}
}
.background(Color(hex: "#F7F7F7"))
}
var tabHeader: some View {
Text("My Header")
.frame(width: UIScreen.main.bounds.width, height: 50)
.background(Color.blue)
}
var topContent: some View {
Text("My scrollable content")
.frame(width: UIScreen.main.bounds.width, height: 200)
}
var scrollable: some View {
TrackableScrollView(contentOffset: $scrollViewOffset) {
VStack {
VStack {
topContent
tabHeader
.opacity(shouldSwap ? 0 : 1)
.rectReader($headerScrollView, in: .global)
}
HStack() {
// ... content
}
}
}
}
var shouldSwap: Bool {
return headerTop.origin.y >= headerScrollView.origin.y
}
}
I'm trying to achieve this by defining my tabHeader view at two places - inside scrollview and at top of scrollView (in ZStack with scrollView) - once headers overlap, I just show top one and hide one from inside the scrollView and it actually looks pretty good, just like list sticky section.
My question is: can I somehow reuse same view to be animated/translated/updated in those two locations (inside scrollView -> swipeUp -> static above scrollView -> swipe down -> inside scrollView), because right now, if there is any internal state in tabHeader, it won't work because those are 2 different views so all the state they have must be in their parent and passed as binding.
If that's not possible, can I somehow restrict view from having internal state by implementing some protocol or something?

SwiftUI: List is messing up animation for its subviews inside an HStack

I'm making a WatchOS app that displays a bunch of real-time arrival times. I want to place a view, a real-time indicator I designed, on the trailing end of each cell of a List that will be continuously animated.
The real-time indicator view just has two image whose opacity I'm continuously animating. This View by itself seems to work fine:
animated view by itself
However, when embedded inside a List then inside an HStack the animation seems to be affecting the position of my animated view not only its opacity.
animated view inside a cell
The distance this view travels seems to only be affected by the height of the HStack.
Animated view code:
struct RTIndicator: View {
#State var isAnimating = true
private var repeatingAnimation: Animation {
Animation
.spring()
.repeatForever()
}
private var delayedRepeatingAnimation: Animation {
Animation
.spring()
.repeatForever()
.delay(0.2)
}
var body: some View {
ZStack {
Image("rt-inner")
.opacity(isAnimating ? 0.2 : 1)
.animation(repeatingAnimation)
Image("rt-outer")
.opacity(isAnimating ? 0.2 : 1)
.animation(delayedRepeatingAnimation)
}
.frame(width: 16, height: 16, alignment: .center)
.colorMultiply(.red)
.padding(.top, -6)
.padding(.trailing, -12)
.onAppear {
self.isAnimating.toggle()
}
}
}
All code:
struct SwiftUIView: View {
var body: some View {
List {
HStack {
Text("Cell")
.frame(height: 100)
Spacer()
RTIndicator()
}.padding(8)
}
}
}
Here is found workaround. Tested with Xcode 12.
var body: some View {
List {
HStack {
Text("Cell")
.frame(height: 100)
Spacer()
}
.overlay(RTIndicator(), alignment: .trailing) // << here !!
.padding(8)
}
}
Although it's pretty hacky I have found a temporary solution to this problem. It's based on the answer from Asperi.
I have create a separate View called ClearView which has an animation but does not render anything visual and used it as a second overall in the same HStack.
struct ClearView: View {
#State var isAnimating = false
var body: some View {
Rectangle()
.foregroundColor(.clear)
.onAppear {
withAnimation(Animation.linear(duration: 0)) {
self.isAnimating = true
}
}
}
}
var body: some View {
List {
HStack {
Text("Cell")
.frame(height: 100)
Spacer()
}
.overlay(RTIndicator(), alignment: .trailing)
.overlay(ClearView(), alignment: .trailing)
.padding(8)
}
}

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.

What is causing my SwiftUI view with higher layout priority to grab all the space?

I'm having problems laying out a VStack in SwiftUI that uses a custom pager controller, and despite trying loads of options I can't get it to behave the way I want.
Sample code:
import SwiftUI
struct ContentView: View {
#State var pageIndex: Int = 0
var body: some View {
VStack {
Text("Headline").font(.title)
Divider()
pagedView.background(Color.pink).layoutPriority(1)
Color.red
Color.blue
Color.gray
}
}
var pagedView: some View {
PagerManager(pageCount: 2, currentIndex: $pageIndex) {
VStack {
Text("Line 1")
Text("Line 2")
Text("Line 3")
Text("Line 4")
Text("Line 5")
Text("Line 6")
Text("Line 7")
Text("Line 8")
}
VStack {
Text("Line 21")
Text("Line 22")
Text("Line 23")
Text("Line 24")
Text("Line 25")
}
}
}
}
struct PagerManager<Content: View>: View {
let pageCount: Int
#Binding var currentIndex: Int
let content: Content
//Set the initial values for the variables
init(pageCount: Int, currentIndex: Binding<Int>, #ViewBuilder content: () -> Content) {
self.pageCount = pageCount
self._currentIndex = currentIndex
self.content = content()
}
#GestureState private var translation: CGFloat = 0
var body: some View {
VStack {
singlePageView.background(Color.yellow)
HStack(spacing: 8) {
ForEach(0 ..< self.pageCount, id: \.self) { index in
CircleButton(isSelected: Binding<Bool>(get: { self.currentIndex == index }, set: { _ in })) {
withAnimation {
self.currentIndex = index
}
}
}
}
}
}
var singlePageView: some View {
GeometryReader { geometry in
HStack(spacing: 0) {
self.content.frame(width: geometry.size.width).background(Color.purple)
}
.frame(width: geometry.size.width, alignment: .leading)
.offset(x: -CGFloat(self.currentIndex) * geometry.size.width)
.offset(x: self.translation)
.animation(.interactiveSpring())
.gesture(
DragGesture().updating(self.$translation) { value, state, _ in
state = value.translation.width
}.onEnded { value in
let offset = value.translation.width / geometry.size.width
let newIndex = (CGFloat(self.currentIndex) - offset).rounded()
self.currentIndex = min(max(Int(newIndex), 0), self.pageCount - 1)
}
)
}
}
}
struct CircleButton: View {
#Binding var isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: {
self.action()
}) { Circle()
.overlay(
Circle()
.stroke(Color.black,lineWidth: 1)
).foregroundColor(self.isSelected ? Color(UIColor.systemGray2) : Color.white.opacity(0.5))
.frame(width: 10, height: 10)
}
}
}
What I'm trying to achieve is for pagedView to get priority for it's own space and then the rest of the VStack views (red, blue, gray in the code) to have the remaining space divided equally between them.
Without layoutPriority(1) on pagedView the vertical space is divided equally between the 4 views (as expected), which does not leave enough space for the Text lines in the pagedView which are squashed into the bottom of the view. (Background colors added to show how the layout is working)
However, adding layoutPriority(1) on the pageView makes the embedded singlePageView (in yellow) grab all of the vertical space so the remaining Colors are not shown.
The issue seems to be somewhere in the singlePageView code, as changing singlePageView.background(Color.yellow) to content in PagerManager VStack means the layout behaves as I want (without the desired paging of course):
I know I could take a different approach and try wrapping a UIPageViewController in a UIViewControllerRepresentable but before I go down that route I'd like to know if there's a way of getting the pure SwiftUI approach to work
I'm not sure if your question is still an open issue for you, but for everybody else with a similar problem, the following explanation might help:
By using the layoutPriority modifier, you are asking SwiftUI to start laying out the body views of the ContentViews VStack with the singlePageView. This means that this view now gets the full available space offered. As you use the GeometryReader as your outermost view, it will claim the maximum space available no space leaving to all the remaining views. GeometryReader is like Color a view which will always occupy the maximum available space offered by the parent view.
(For more information about the SwiftUI layout process, read for example How layout works in SwiftUI from HackingWithSwiftUI.)
If you remove the layoutPriority modifier, all the views are equally important to the parent and therefore each of them will get a quarter of the available space. This means for your singlePageView that it is not enough to show it's full content: The GeometryReader could only claim a quarter of the available height.
Your main goal is, that the PagerManager is occupying the full horizontal screen space available but uses only the minimum required vertical space. You should therefore not use the GeometryReader as your main view in singlePageView as this will influence how its content will be laid out. But nevertheless you have to size the content of the page view to occupy the full width.
What can you do get your desired layout?
My solution:
Introduce a state variable pageWidth in PagerManager:
#State private var pageWidth: CGFloat = 600
Measure the screen width using GeometryReader on the background of the circle buttons parent HStack (the .frame(maxWidth: .infinity) modifier ensures that it occupies the whole available screen width):
HStack(spacing: 8) {
ForEach(0 ..< self.pageCount, id: \.self) { index in
// ... omitted CircleButton code
}
}
// Measure the width of the screen using GeometryReader on the background
.frame(maxWidth: .infinity)
.background(
GeometryReader { proxy -> Color in
DispatchQueue.main.async {
pageWidth = proxy.size.width
}
return Color.clear
}
)
Then in singlePageView remove the GeometryReader and replace all occurrences of geometry.size.width with pageWidth:
var singlePageView: some View {
HStack(spacing: 0) {
self.content.frame(width: pageWidth).background(Color.purple)
}
.frame(width: pageWidth, alignment: .leading)
.offset(x: -CGFloat(self.currentIndex) * pageWidth)
.offset(x: self.translation)
.animation(.interactiveSpring())
.contentShape(Rectangle()) // ensures we can drag on the transparent background
.gesture(
DragGesture().updating(self.$translation) { value, state, _ in
state = value.translation.width
}.onEnded { value in
let offset = value.translation.width / pageWidth
let newIndex = (CGFloat(self.currentIndex) - offset).rounded()
self.currentIndex = min(max(Int(newIndex), 0), self.pageCount - 1)
}
)
}
As you can see I've added .contentShape(Rectangle()) modifier to ensure that the DragGesture also works on the empty background.

Square Text using aspectRatio in SwiftUI

I'm trying to achieve a following layout using Swift UI…
struct ContentView : View {
var body: some View {
List(1...5) { index in
HStack {
HStack {
Text("Item number \(index)")
Spacer()
}.padding([.leading, .top, .bottom])
.background(Color.blue)
Text("i")
.font(.title)
.italic()
.padding()
.aspectRatio(1, contentMode: .fill)
.background(Color.pink)
}.background(Color.yellow)
}
}
}
I'd like the Text("i") to be square, but setting the .aspectRatio(1, contentMode: .fill) doesn't seem to do anything…
I could set the frame width and height of the text so it's square, but it seems that setting the aspect ratio should achieve what I want in a more dynamic way.
What am I missing?
I think this is what you're looking for:
List(1..<6) { index in
HStack {
HStack {
Text("Item number \(index)")
Spacer()
}
.padding([.leading, .top, .bottom])
.background(Color.blue)
Text("i")
.font(.title)
.italic()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.aspectRatio(1, contentMode: .fill)
.background(Color.pink)
.fixedSize(horizontal: true, vertical: false)
.padding(.leading, 6)
}
.padding(6)
.background(Color.yellow)
}
The answer being said, i don't recommend giving SwiftUI too much freedom to decide the sizings. one of the biggest SwiftUI problems right now is the way it decides how to fit the views into each other. if something goes not-so-good on SwiftUI's side, it can result in too many calls to the UIKit's sizeToFit method which can slowdown the app, or even crash it.
but, if you tried this solution in a few different situations and it worked, you can assume that in your case, giving SwiftUI the choice of deciding the sizings is not problematic.
The issue is due to used different fonts for left/right sides, so paddings generate different resulting area.
Here is possible solution. The idea is to give right side rect based on default view size of left side text (this gives ability to track dynamic fonts sizes as well, automatically).
Tested with Xcode 12 / iOS 14
struct ContentView: View {
#State private var height = CGFloat.zero
var body: some View {
List(1...5, id: \.self) { index in
HStack(spacing: 8) {
HStack {
Text("Item number \(index)")
Spacer()
}
.padding([.leading, .top, .bottom])
.background(GeometryReader {
Color.blue.preference(key: ViewHeightKey.self, value: $0.frame(in: .local).size.height)
})
Text("i")
.italic()
.font(.title)
.frame(width: height, height: height)
.background(Color.pink)
}
.padding(8)
.background(Color.yellow)
.onPreferenceChange(ViewHeightKey.self) {
self.height = $0
}
}
}
}
struct ViewHeightKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
I managed to recreate the view in your first screenshot in SwiftUI. I wasn't sure on how much padding you wanted so I defined a private immutable variable for this value
The blue view is the one that will have the text content and could change in size so by using a GeometryReader you can get the size of the blue view and then use the height value from the size to set the width and height of the pink view. This means that whatever the height of the blue view is, the pink view will follow keeping an equal aspect ratio
The SizeGetter view below is used to get any views size using a GeometryReader and then binds that value back to a #State variable in the ContentView. Because the #State and #Binding property wrappers are being used, whenever the blueViewSize is updated SwiftUI will automatically refresh the view.
The SizeGetter view can be used for any view and is implemented using the .background() modifier as shown below
struct SizeGetter: View {
#Binding var size: CGSize;
var body: some View {
// Get the size of the view using a GeometryReader
GeometryReader { geometry in
Group { () -> AnyView in
// Get the size from the geometry
let size = geometry.frame(in: .global).size;
// If the size has changed, update the size on the main thread
// Checking if the size has changed stops an infinite layout loop
if (size != self.size) {
DispatchQueue.main.async {
self.size = size;
}
}
// Return an empty view
return AnyView(EmptyView());
}
}
}
}
struct ContentView: View {
private let padding: Length = 10;
#State private var blueViewSize: CGSize = .zero;
var body: some View {
List(1...5) { index in
// The yellow view
HStack(spacing: self.padding) {
// The blue view
HStack(spacing: 0) {
VStack(spacing: 0) {
Text("Item number \(index)")
.padding(self.padding);
}
Spacer();
}
.background(SizeGetter(size: self.$blueViewSize))
.background(Color.blue);
// The pink view
VStack(spacing: 0) {
Text("i")
.font(.title)
.italic();
}
.frame(
width: self.blueViewSize.height,
height: self.blueViewSize.height
)
.background(Color.pink);
}
.padding(self.padding)
.background(Color.yellow);
}
}
}
In my opinion it is better to set the background colour of a VStack or HStack instead of the Text view directly because you can then add more text and other views to the stack and not have to set the background colour for each one
I was searching very similar topic "Square Text in SwiftUI", came across your question and I think I've found quite simple approach to achieve your desired layout, using GeometryProxy to set width and heigh of the square view from offered geometry.size.
Checkout the code below, an example of TableCellView which can be used within List View context:
import SwiftUI
struct TableCellView: View {
var index: Int
var body: some View {
HStack {
HStack {
Text("Item number \(index)")
.padding([.top, .leading, .bottom])
Spacer()
}
.background(Color(.systemBlue))
.layoutPriority(1)
GeometryReader { geometry in
self.squareView(geometry: geometry)
}
.padding(.trailing)
}
.background(Color(.systemYellow))
.padding(.trailing)
}
func squareView(geometry: GeometryProxy) -> some View {
Text("i")
.frame(width: geometry.size.height, height: geometry.size.height)
.background(Color(.systemPink))
}
}