SwiftUI get the offset of a view inside a scrollview - swiftui

Im trying to create a sticky header and change the background color to a different color once the header yOffset is 0. This is how my code looks like.
struct TestView: View {
var body: some View {
ScrollView {
LazyVStack(spacing: 20, pinnedViews: .sectionHeaders) {
Image(systemName: "xmark")
.resizable()
.scaledToFit()
.background(.red)
Section {
Text("SOME REALLY LONG TEST\n\nSOME REALLY LONG TEST\n\nSOME REALLY LONG TEST\n\nSOME REALLY LONG TEST\n\nSOME REALLY LONG TEST\n\nSOME REALLY LONG TEST\n\nSOME REALLY LONG TEST\n\nSOME REALLY LONG TEST\n\nSOME REALLY LONG TEST\n\nSOME REALLY LONG TEST\n\nSOME REALLY LONG TEST\n\nSOME REALLY LONG TEST\n\nSOME REALLY LONG TEST\n\nSOME REALLY LONG TEST\n\nSOME REALLY LONG TEST\n\nSOME REALLY LONG TEST\n\nSOME REALLY LONG TEST\n\nSOME REALLY LONG TEST\n\nSOME REALLY LONG TEST\n\nSOME REALLY LONG TEST\n\nSOME REALLY LONG TEST\n\nSOME REALLY LONG TEST\n\nSOME REALLY LONG TEST\n\nSOME REALLY LONG TEST\n\nSOME REALLY LONG TEST\n\nSOME REALLY LONG TEST\n\nSOME REALLY LONG TEST\n\nSOME REALLY LONG TEST\n\nSOME REALLY LONG TEST")
} header: {
Text("Title")
.font(.largeTitle.bold())
.frame(maxWidth: .infinity)
.padding(.top, 40)
.padding(.bottom, 20)
.background(
GeometryReader { reader -> Color in
let minY = reader.frame(in: .global).minY
print("Y +\(minY)")
return minY > 0 ? .white : .blue
}
)
}
}
}
.ignoresSafeArea()
}
}
Now this works but it causes the background to glitch sometimes. What I mean by this is when I keep scrolling down it sometimes switches back to white. I don't know if I am doing this right. Is there another way to do this without the background glitching?

Related

SwiftUI String formatting is rounding my values to nearest whole number?

I hope you're all well!
I have a (potentially) silly question. I'm attempting to learn SwiftUI development by recreating the Instagram UI.
Right now, I'm working on the user page that shows a single user and their profile statistics. I'm seeing some weird behaviour with my string formatting.
HStack (spacing: 24){
VStack (alignment: .center, spacing: 2) {
Text(postCountString)
.font(.system(size: 16, weight: .bold))
Text("Posts")
.font(.system(size: 13, weight: .regular))
}
VStack (alignment: .center, spacing: 2) {
Text(followerCountString)
.font(.system(size: 16, weight: .bold))
Text("Followers")
.font(.system(size: 13, weight: .regular))
}
VStack (alignment: .center, spacing: 2) {
Text(followsCountString)
.font(.system(size: 16, weight: .bold))
Text("Follows")
.font(.system(size: 13, weight: .regular))
}
}
.frame(maxWidth: .infinity)
.padding(.leading, 16)
.onAppear {
postCountString = UIHelperFunctions.FormatCountNumber(number: creator.posts.count)
followerCountString = UIHelperFunctions.FormatCountNumber(number: creator.followerCount)
followsCountString = UIHelperFunctions.FormatCountNumber(number: creator.followsCount)
}
Above is the View component that shows the main statistics of a creator (Posts, Followers, Follows). When the view is displayed, it should run each statistic through my UIHelperFunction that formats the number into a nice readable string see function
import Foundation
class UIHelperFunctions {
static func FormatCountNumber(number: Int) -> String {
guard number > 1000 else {
return String(number)
}
guard number > 10000 else {
return String(format: "%.1fK", Double(number/1000))
}
guard number > 1000000 else {
return String(format: "%dK", number/1000)
}
return String(format: "%.1fM", Double(number/1000000))
}
}
// Example with input: 32495
print(UIHelperFunctions.FormatCountNumber(number: 32495)) // Output: 32K
However, when I input a value between 1000 and 10000, it returns a close value; however, it leaves the decimal place as 0. Below is an image that shows what I see on the screen when I input the value 6748. I would expect it to output 6.7K, but I'm seeing 6.0K.
Am I doing something silly? If not, am I misunderstanding how floating point maths works in SwiftUI?
Any help would be greatly appreciated; let me know if you need more context or code.
Thanks in advance :)
Int divided on Int will be Int, converting it to Double is too late, you need instead to divide double on double, like
guard number > 10000 else {
return String(format: "%.1fK", Double(number)/Double(1000)))
}
SwiftUI does formatting for us and it automatically updates the UILabel on screen when the region settings change, simply do:
Text(creator.posts.count, format: .number)
or
Text("Count: \(creator.posts.count, format: .number)")
To get the M, K etc. in the future this should work (if this ever gets approved)
extension NumberFormatter {
static var compact: NumberFormatter = {
let f = NumberFormatter()
f.numberStyle = .compact
return f
}()
}
Text(creator.posts.count, formatter: NumberFormatter.compact)

In SwiftUI, how do I initialize an animated value based on the screen height?

I am new to SwiftUI but a very experienced developer (which may even be a hindrance here). I have a full-screen, highly graphical app/game. One of the requirements is for a specific image to animate by floating up and down. Doing this is easy:
#State var y : CGFloat = 400
var rockFloatExtent: CGFloat = 140
var body: some View {
VStack {
GeometryReader { geometry in
Image("Earth1Rocks2")
.resizable()
.scaledToFit()
.position(x: geometry.size.width / 2, y: self.y)
.animation(Animation.easeInOut(duration: 5.0).repeatForever(autoreverses: true), value: self.y)
.onAppear {
self.y = geometry.size.height * percentRockStartHeight + rockFloatExtent
}
}
...
The problem is the the initial value is going to depend on the state of the game. Sometimes, the y value may be 25% of the screen height (again, full-screen app) and other times it may start at 50% of screen height. At least in the emulator UIScreen.main.bounds.size.height seems to be simply wrong with respect to the device height - it doesn't agree with the GeometryReader at all. I would love an easy way to init the value OR some trick with the animation to make this work.
I have tried doing an .onAppear with an initial value and, after a very brief pause, a second value. But the animation just ignores the first value or varies between all three. I have tried initializing the position using GeometryReader but then the element's y value isn't tied to self.y and the animation breaks. I don't know how to reference (and animate) the actual position of the element either without a state variable as an intermediary.
Any help would be greatly appreciated!
Much simpler - just switch frame alignment and everything else will be done by SwiftUI:
#State private var isAnimating = false // << state !!
var body: some View {
Image(systemName: "globe") // << just for demo
.resizable().scaledToFit()
.frame(maxHeight: .infinity, alignment: isAnimating ? .top : .bottom) // << switch !!
.animation(Animation.easeInOut(duration: 5.0).repeatForever(autoreverses: true), value: isAnimating)
.onAppear {
isAnimating = true // << activate !!
}
}
Tested with Xcode 13.4 / iOS 15.5
So as you have a game the phone is potentially landscape mode. If so UIScreen.main.bounds.size.height will give you the height of the phone and not the height of the screen, but the width of the screen. UIScreen.main.bounds.size.height does not react on device rotation. It stays the same.
Maybe that the issue you're facing.
Hope that helps.
The solution of #Asperi looks good to me.

How can I vertically align an image to the center of the first line of some text?

I have a bullet point and some long, multiline text. I want the bullet point to be aligned with the center of the first line of text. Obviously, If the string is sufficiently short and one line long then the two views are automatically center aligned. I'm interested in cases where text is more than one line long.
var body: some View {
HStack {
Image(systemName: "circle.fill")
.font(.system(size: 8))
Text("Insert really long multiline string that wraps.")
}
}
Is this possible?
Update 1:
Setting the HStack alignment to top aligns the top of the image with the top of the text, like this...
var body: some View {
HStack(alignment: .top) {
Image(systemName: "circle.fill")
.font(.system(size: 8))
Text("This is really really really really really really really really really really really really really really really really really really really really really really really really long string.")
}
}
Update 2:
The only option I can think of is something like this, except this is UIKit...
// Aligns the icon to the center of a capital letter in the first line
let offset = label.font.capHeight / 2.0
// Aligns the icon to the center of the whole line, which is different
// than above. Especially with big fonts this makes a visible difference.
let offset = (label.font.ascender + label.font.descender) / 2.0
let constraints: [NSLayoutConstraint] = [
imageView.centerYAnchor.constraint(equalTo: label.firstBaselineAnchor, constant: -offset),
imageView.trailingAnchor.constraint(equalTo: label.leadingAnchor, constant: -10)
]
NSLayoutConstraint.activate(constraints)
On iOS 14 you can use Label for that.
Label {
Text("This is really really really really really really really really really really really really really really really really really really really really really really really really long string.")
} icon: {
Image(systemName: "circle.fill")
.font(.system(size: 8))
}
Here is a solution based on custom alignment. Tested with Xcode 11.4 / iOS 13.4
extension VerticalAlignment {
private enum XAlignment : AlignmentID {
static func defaultValue(in d: ViewDimensions) -> CGFloat {
return d[VerticalAlignment.top]
}
}
static let xAlignment = VerticalAlignment(XAlignment.self)
}
struct DemoAlignFirstLine: View {
var body: some View {
HStack(alignment: .xAlignment) {
Image(systemName: "circle.fill")
.font(.system(size: 8))
.alignmentGuide(.xAlignment) { $0.height / 2.0 }
ZStack(alignment: .topLeading) {
// invisible anchor, should be of same font as text
Text("X").foregroundColor(.clear)
.alignmentGuide(.xAlignment) { $0.height / 2.0 }
// main text
Text("This is really really really really really really really really really really really really really really really really really really really really really really really really long string.")
}
}
}
}
Assuming that all lines of text should have equal heights/distance between the baselines, we can benefit from the fact that dimensions provided to .alignmentGuide have both first and last baselines, and get the height of the first (in fact, any) line simply by subtracting the distance between the last and the first baselines from the view height.
Reusing the terms of the answer provided by #Asperi, here's the solution that does not require introducing ZStack/fake view:
extension VerticalAlignment {
private enum XAlignment : AlignmentID {
static func defaultValue(in d: ViewDimensions) -> CGFloat {
return d[VerticalAlignment.top]
}
}
static let xAlignment = VerticalAlignment(XAlignment.self)
}
struct DemoAlignFirstLine: View {
var body: some View {
HStack(alignment: .xAlignment) {
Image(systemName: "circle.fill")
.font(.system(size: 8))
.alignmentGuide(.xAlignment) { $0.height / 2.0 }
// main text
Text("This is really really really really really really really really really really really really really really really really really really really really really really really really long string.")
.alignmentGuide(.xAlignment) {
($0.height - ($0[.lastTextBaseline] - $0[.firstTextBaseline])) / 2
}
}
}
}

is there a easy way to align subviews?

my code is like the following, I want the four images aligned to 2*5, alignmentGuider works here, while, is there an easier way can get the same result?
struct ContentView: View {
var body: some View {
ForEach(0..<10id:\.self){ _ in
Image(systemName: "star")
.resizable()
.frame(width: 100,height: 150)
}
}
}
I've used a ScrollView(.horizontal) instead, it is more conform to SwiftUI's design maybe.

ScrollView: How to handle large amount of content?

This piece of code is kind of stress test for a ScrollView. While a ForEach loop with 3000 items is rendering acceptably fast, 30000 takes long, and 300000 forever (I stopped after 3 minutes).
However, there are situations where you have a lot of content to scroll, imagine a timeline with zoomable scales (decade / year / month / day). In this case, there may be days across 50 years to display, and the user must have the chance to zoom in and out in order to change the scale.
The question for me is therefore: what strategies are possible with SwiftUI in order to optimize caching, prefetching etc., or to find out which part is to be displayed next after the user scrolled, and to prevent the model from calculating widths?
To be more precise: The model knows all data to display. The ScrollView must be provided with a content view that has the width of all items to display. This width is my problem: how can it be determined in a way that makes sense, and without having all items to be available?
struct TestScrollView: View {
var body: some View {
ScrollView(.horizontal, showsIndicators: true, content: {
HStack(alignment: .bottom, spacing: 1) {
// 3000 is working, 30000 not well, 300000 takes forever:
ForEach(1 ..< 30000) { index in
Rectangle()
.fill(Color.yellow)
.frame(minWidth: 100, idealWidth: 200, maxWidth: 300, minHeight: 500, idealHeight: 500, maxHeight: 500, alignment: .bottom)
}
}
})
}
}
``
There was a trick old times for having horizontal UITableView that is using now for reversal filling (bottom to top) that you can use it here:
Use a vertical list
Rotate it 90˚
Rotate all items of it -90˚
now you have horizontal List
struct ContentView: View {
var body: some View {
List {
ForEach(1 ..< 30000) { index in
Text("\(index)")
.rotationEffect(.init(degrees: -90))
}
}
.rotationEffect(.init(degrees: 90))
.position(x: 10, y: 200) // Set to correct offset
}
}
Why are you using a Scrollview instead of a List? A List would reuse the cells which is much better for performance and memory management. With the list when a cell scrolls out of sight, it gets removed from the view which helps greatly with performance.
For more information about the difference in performance please see here
var body: some View
{
List
{
ForEach(1..< 30000) {
index in
Rectangle()
.fill(Color.yellow)
.frame(minWidth: 100, idealWidth: 200, maxWidth: 300, minHeight: 500, idealHeight: 500, maxHeight: 500, alignment: .bottom)
}
}
}
Edit: Since I miss-read the original question because the scrollview is horizontal.
An option would be to wrap an UICollectionView into UIViewRepresentable see here for the dev talk and here for an example implementation this would let you display it as a horizontal view while getting the performance benefits of a list