SwiftUI: How would I make stretchable (flexible) sticky header for ScrollView? - ios

Well, honestly, I did it, because I needed it, and only then looked around and did not find anything on SO native in SwiftUI, so wanted to share. Thus this is just a self-answered question.
Initially I needed sticky stretchable sticky header for lazy content dependent only on ScrollView.
Later (after I got my solution) I found this one on Medium, but I don't like it (and would not recommend at least as-is), because:
overcomplicated (many unneeded code, many unneeded calculations)
depends (and joins) with safe area only, so limited applicability
based on offset (I don't like to use offset, because of its inconsistency with layout, etc.)
it is not sticky and to make it sticky it is needed even more code
So, actually all this text was just to fulfil SO question requirements - who knows me here knows that I don't like to type many text, it is better to type code šŸ˜€, in short - my approach is below in answer, maybe someone find it useful.
Initial code which SwiftUI gives us for free
ScrollView {
LazyVStack(spacing: 8, pinnedViews: [.sectionHeaders]) {
Section {
ForEach(0...100) {
Text("Item \($0)")
.frame(maxWidth: .infinity, minHeight: 60)
}
} header: {
Image("picture").resizable().scaledToFill()
.frame(height: 200)
}
}
}
Header is sticky by scrolling up, but not when down (dragged with content), and it is not stretchable.

iOS 15.5 (initial)
Ok, we need to solve two problems:
make top of header pinned to top of ScrollView on drag down
stretch header on drag down to make header content (image in majority of cases) scale to fill
A possible approach to solve this:
ScrollView now manages content offsets privately (UIKit variants are out of topics here), so to pin to top using overlay
ScrollView {
// ...
}
.overlay(
// >> any header
Image("picture").resizable().scaledToFill()
// << header end
.frame(height: imageHeight) // will calculate below
.clipped()
Use Section default header (as placeholder) to calculate current distance from ScrollView top
Section(...) {
// ...
} header: {
// here is only caculable part
GeometryReader {
// detect current position of header bottom edge
Color.clear.preference(key: ViewOffsetKey.self,
value: $0.frame(in: .named("area")).maxY)
}
.frame(height: headerHeight)
.onPreferenceChange(ViewOffsetKey.self) {
// prevent image negative height if header is not pinned
// for simplicity (can be optional, etc.)
imageHeight = $0 < 0 ? 0.001 : $0
}
}
That's actually it, everything else is just for demo part.
Tested with Xcode 13.4 / iOS 15.5
Test module is here

If you need solution based on ScrollView:
1/ find scrollOffset (see example or use ScrollViewWithScrollOffset)
2/ wrap image with GeometryReader to avoid frame glitches and get normal image size on device screen
3/ use size from GeometryReader and scrollOffset to set image frame
Full code:
struct ContentView: View {
#State var scrollOffset: CGFloat = 0
private let coordinateSpaceName = "scrollViewSpaceName"
var body: some View {
ScrollView {
VStack {
image
Color.gray.frame(height: 1000)
}
.background( // 1. find scrollOffset
GeometryReader { proxy in
let offset = proxy.frame(in: .named(coordinateSpaceName)).minY
Color.clear.preference(key: ScrollViewWithPullDownOffsetPreferenceKey.self, value: offset)
}
)
}
.coordinateSpace(name: coordinateSpaceName)
.onPreferenceChange(ScrollViewWithPullDownOffsetPreferenceKey.self) { value in
scrollOffset = value
}
}
var image: some View { frame
GeometryReader { proxy in // 2. get actual size on screen
Image(systemName: "heart.fill") // 3. use scrollOffset to adjust image
.resizable()
.aspectRatio(contentMode: .fit)
.padding(.horizontal, min(0, -scrollOffset))
.frame(width: proxy.size.width,
height: proxy.size.height + max(0, scrollOffset))
.offset(CGSize(width: 0, height: min(0, -scrollOffset)))
}
.aspectRatio(CGSize(width: 375, height: 280), contentMode: .fit)
}
}

Related

`maxHeight` behaves exactly as `height` frame modifier

I'm confused by frame(maxHeight: ...) modifier. I would expect that the resulting view would have dynamic height capped at maxHeight. However, the height of the view is always maxHeight.
In the example below, I wanted the green rectangle to have 30px and the red one should fill the rest space. However, there's additional padding between the rectangles caused by maxHeight modifier.
Is there any other way to achieve what I want?
maxHeight seems useless to me know. It's pretty much the same as using height, isn't it?
Code
var body: some View {
VStack(spacing: 0) {
Color.green
.frame(height: 30)
.frame(maxHeight: 60, alignment: .top)
Color.red
}
.frame(height: 100)
.background(Color.black)
}
Preview:
Every modifier can (in general!) create a new view. So, you create at first green view unlimited, then limit it by 30px (so green filled that space), and then create another view with 60px (and because there is no stopper it filled that available space)... and then everything with red view below(!) considered above views. To get what you expected just remove .frame(maxHeight - it is not needed here.
... but, if you still want (for any reason) to keep it and fulfil expectation, then it is a matter for layout order - give a preference to second view, like
VStack(spacing: 0) {
Color.green
.frame(height: 30)
.frame(maxHeight: 60, alignment: .top)
Color.red
.layoutPriority(1) // << here !!
}
and you get

SwiftUI alignment of "rating" views in two separate rows in Form for macOS and iOS

Writing a Multiplatform app initially for macOS and iOS that uses a master detail view structure and within the detail view is a Form and many Sections, one of which contains two structs that represent a user's rating (of food) for enjoyment and consumption.
Here is the code for the enjoyment rating...
struct Rating: View {
#Environment(\.colorScheme) var colourScheme
#Binding var rating: Int
var body: some View {
HStack {
Text("Enjoyment")
.padding(.trailing, 12.0)
#if os(iOS)
Spacer()
#endif
ForEach(0..<5) { counter in
Image(systemName: rating > counter ? "star.fill" : "star")
.onTapGesture(count: 1) {
if rating == 1 {
rating = 0
}
else {
rating = counter + 1
}
}
// this line for image system name = "circle.fill" : "circle"
//.padding(.trailing, counter != 4 ? 2.5 : 0)
}
.shadow(color: colourScheme == .light ? .gray : .white, radius: colourScheme == .light ? 1.0 : 2.0, x: 0, y: 0)
}
.foregroundColor(Color.accentColor)
}
}
The consumption rating is very similar with minor changes.
So far these look good on iOS because the (iOS only) Spacer() pushes each Rating view to the right or trailing edge of the row and my .padding modifier hack for the circle image makes the spacing between each image "about right".
While I'm struggling to figure out how to align the "dots" for the macOS target, I'm also struggling to figure out how to align each image programmatically, so that if I changed the image the alignment would work.
See screenshots below (that illustrate how the five Images do not align).
iOS
macOS
I've read a few blogs on the .alignmentGuide modifier including Alignment guides in SwiftUI by Majid Jabrayilov.
It seems to be the way I should go but I'm stuck in my attempts on how to work this out.
I've added a comment about the use of Spacer() between your label and your rating control, but separately it's worth looking at the rating control itself.
Firstly, right now you're relying on the five elements sharing an HStack with the label, and using conditional padding logic within the loop to control the spacing between elements.
That part would be easier if you give your rating element its own HStack. That way, spacing between elements can be determined using the stack's spacing attribute without having to worry about whether or not you're on the last loop iteration. For example:
HStack(spacing: 2.5) {
ForEach(0..<5) {
Image(systemName: "circle")
// etc.
}
}
In terms of aligning the child elements of a rating view so that they align with a similar view below regardless of the symbol being used, you can constrain the frame width of each child element to be the same, regardless of what image they're displaying.
You can accomplish that by adding a .frame() modifier to each child element in the loop:
HStack {
ForEach(0..<5) {
Image(systemName: "xxx")
.frame(width: 40, alignment: .center)
}
}
You'd obviously need to pick a width that works for you - and you could mix this with a spacing attribute on the HStack as well.

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:
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.
You can use dynamic frame modifiers, such as frame(.maxWidth: .infinity) modifier to extend views so that they fill up the entire frame, even if the frame is dynamic. Here is an example that should help you get going:
struct CustomContent: View {
var body: some View {
VStack {
VStack {
CustomRow(timeText: "9am", systemIcon: "cloud.drizzle", centerText: "Summary", temperature: "12Ā°", percent: "25%")
CustomRow(timeText: "10am", systemIcon: nil, centerText: nil, temperature: "13Ā°", percent: "25%")
}
VStack {
CustomRow(timeText: "9am", systemIcon: "cloud.drizzle", centerText: "Summary", temperature: "12Ā°", percent: "25%")
CustomRow(timeText: "10am", systemIcon: nil, centerText: nil, temperature: "13Ā°", percent: "25%")
}
.frame(width: 300)
}
}
}
struct CustomContent_Previews: PreviewProvider {
static var previews: some View {
CustomContent()
}
}
struct CustomRow: View {
let timeText: String
let systemIcon: String?
let centerText: String?
let temperature: String
let percent: String
var body: some View {
HStack {
//Left column
HStack(alignment: .center) {
Text(timeText)
if let icon = systemIcon {
Image(systemName: icon)
.font(.title2)
}
}
.padding(.all)
.frame(width: 105, height: 60)
.background(Color.blue.opacity(0.2))
.cornerRadius(16)
// Center column
ZStack(alignment: .leading) {
Capsule()
.fill(Color.black.opacity(0.3))
.frame(height: 0.5)
if let text = centerText {
Text(text)
.lineLimit(1)
.background(Color.white)
}
}
// Right column
VStack {
Text(temperature)
Text(percent)
.foregroundColor(Color.black)
}
}
}
}
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())
}
I had similar issue. My text in one of the label in a row was varying from 2 characters to 20 characters. It messes up the horizontal alignment as you have seen. I was looking to make this column in row as fixed width. I came up with something very simple. And it worked for me.
var body: some View { // view for each row in list
VStack(){
HStack {
Text(wire.labelValueDate)
.
.
.foregroundColor(wire.labelColor)
.fixedSize(horizontal: true, vertical: false)
.frame(width: 110.0, alignment: .trailing)
}
}
}

Update SwiftUI View size to match image

I'm trying to make a view which holds an image loaded asynchronously from a network request. Before the image loads, I want to show a placeholder view which has a fixed size. When the image loads, I want to replace this placeholder view with the image, scaled to fit inside the frame of the placeholder view, but then I want the parent view to shrink to match the size of this image. I can't figure out how to do this last part.
Right now, it looks like this:
struct ItemCell: View {
var body: some View {
Group {
CustomImageView(from: imageURL, placeholder: PlaceholderView(), config: { $0.resizable() })
.aspectRatio(contentMode: .fit)
.frame(minWidth: 0, maxWidth: 150, minHeight: 0, maxHeight: 190, alignment: .bottomLeading)
}.background(Color.red) // To show that the view isn't resizing properly
}
}
struct PlaceholderView: View {
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 3, style: .continuous)
.frame(width: 150, height: 190)
.foregroundColor(Color(.secondarySystemBackground))
Image(systemName: "globe")
.resizable()
.scaledToFit()
.frame(width: 50)
.foregroundColor(.secondary)
}
}
}
The CustomImageView is adapted from this article on loading images asynchronously. The ItemCells are placed in a horizontal ScrollView. When I test this, it:
correctly displays the placeholder view before the image is loaded;
resizes the image so it maintains its aspect ratio and fits inside the 150x190 frame, but has a weird animation where some of the images shrink and then expand back; also, some of the images seem to shrink too much;
does not resize the parent view to match the size of the image properly, but instead retains the full original height and some (?) extra width on some cells.
These two problems are shown in the gif below, with blue images and a red background. Notice the extra height on the first and third cells, and the extra width on the second. Also, note that the first image ends up smaller than when it first loads, even though it fit inside the original 150x190 frame at first.
How can I fix these problems?
Figured out how to do it. There were several problems with my original code. First, the ItemCells used in the ScrollView should be modified with the .fixedSize() view modifier, like so:
ScrollView(...) {
HStack(...) {
ForEach(...) { ...
ItemCell()
.fixedSize()
}
}
}
Then, changing the frame of the CustomImageCell to be use idealHeight instead of maxHeight and making the Group a VStack with a Spacer() to push everything to the bottom, as #Paulw11 had suggested in comments:
struct ItemCell: View {
var body: some View {
VStack {
Spacer()
CustomImageView(from: imageURL, placeholder: PlaceholderView(), config: { $0.resizable() })
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 150, idealHeight: 190)
}
}
}
These changes fix both the image resizing animation issue and the extra space issue.

SwiftUI - Vertical Centering Content inside Scrollview

i'm trying to code a simple login page on my app. I started using SwiftUI on my newlly updated Mac OS Catalina. The Apple documentation is still lacking a lot.
I need to center a VStack vertically on a Scrollview ocupying the whole page with a "limit" on it's width of 400.
Something like this:
ScrollView(.vertical) {
VStack {
Text("Hello World")
}
.frame(maxWidth: 400, alignment: .center)
}
It was easy with UIScrollView, just needed to set the ContentView to fill height and width and then centering a Vertical StackLayout inside the Content View but now with SwiftUI i just wonder..
The goal is something like this (Credit to the author)
If someone is wondering why i want everything inside a scrollview, it's beacause my form is quite big and i expect the user to use both landscape and portrait view so i really need the content to be scrollable, bear in mind also that in a Ipad the form doens't fill the whole screen that's why i want it centered vertically.
You can vertically center content in a scroll view by using GeometryReader to get the parent view's dimensions and setting the scroll view's content's minHeight to the parent's height.
When the content is too big to fit vertically it'll just scroll like normal.
For example:
var body: some View {
GeometryReader { geometry in // Get the geometry
ScrollView(.vertical) {
VStack {
Text("Form goes here")
.frame(maxWidth: 400) // Set your max width
}
.padding()
.background(Color.yellow)
.frame(width: geometry.size.width) // Make the scroll view full-width
.frame(minHeight: geometry.size.height) // Set the contentā€™s min height to the parent
}
}
}
I've build a more generic view based on #Alex answer
/// Custom vertical scroll view with centered content vertically
///
struct VScrollView<Content>: View where Content: View {
#ViewBuilder let content: Content
var body: some View {
GeometryReader { geometry in
ScrollView(.vertical) {
content
.frame(width: geometry.size.width)
.frame(minHeight: geometry.size.height)
}
}
}
}
You can use it anywhere in your app like this
var body: some View {
VScrollView {
VStack {
Text("YOUR TEXT HERE")
}
}
}

Resources