SwiftUI: Infinite Scrolling Horizontal ScrollView - ios

I need to create an infinite horizontal scrolling with ScrollView to scroll a panorama image. So I would like to make this scrolling infinite from both sides, is there any possible way to make it? I have searched maybe using ScrollViewReader can achieve this but no luck.
ScrollView(.horizontal, showsIndicators: false) {
Image("panorama")
.resizable()
.scaledToFill()
}

Actually for such prepared image it is enough to load it only once, and we need only to Image presenters for it (note: they do not copy memory, but use the same loaded image). Everything else is just about layout on the fly moving current of-screen item depending on drag direction to left or to the right of current one.
Tested with Xcode 13.4 / iOS 15.5
Main part of code:
struct PanoramaView: View {
let image: UIImage
private struct Item: Identifiable, Equatable {
let id = UUID()
var pos: CGFloat!
}
#State private var items = [Item(), Item()]
// ...
var body: some View {
GeometryReader { gp in
let centerY = gp.size.height / 2
ForEach($items) { $item in
Image(uiImage: image)
.resizable().aspectRatio(contentMode: .fill)
.position(x: item.pos ?? 0, y: centerY)
.offset(x: dragOffset)
}
}
.background(GeometryReader {
Color.clear.preference(key: ViewSizeKey.self,
value: $0.frame(in: .local).size)
})
.onPreferenceChange(ViewSizeKey.self) {
setupLayout($0)
}
.contentShape(Rectangle())
.gesture(scrollGesture)
}
and usage
let panorama = UIImage(contentsOfFile: Bundle.main.path(forResource: "panorama", ofType: "jpeg")!)!
var body: some View {
PanoramaView(image: panorama)
.frame(height: 300) // to demo of dynamic internal layout
}
Complete test code is here

You can put 3 identical panorama images next to each other (to be able to scroll over the edges) and add a custom DragGesture, that basically jumps back to the relative position of the middle image.
This image is a bad example, obviously it will work better with a real 360° image ;)
EDIT:
now with predicted end location + animation and 5 images.
struct ContentView: View {
#State private var dragOffset = CGFloat.zero
#State private var offset = CGFloat.zero
let imageWidth: CGFloat = 500
var body: some View {
HStack(spacing: 0) {
ForEach(0..<5) { _ in
Image("image")
.resizable()
.frame(width: imageWidth)
}
}
.offset(x: offset + dragOffset)
.gesture(
DragGesture()
.onChanged({ value in
dragOffset = value.translation.width
})
.onEnded({ value in
withAnimation(.easeOut) {
dragOffset = value.predictedEndTranslation.width
}
offset = (offset + dragOffset).remainder(dividingBy: imageWidth)
dragOffset = 0
})
)
}
}

Related

Variable view size for Text views embedded within GeometryReader

I have a list of Texts in a LazyVStack that is embedded within a GeometryReader like so
#State var profilePictureEnlarge: Bool = false
#State var showLiveTutorial: Bool = false
#State var textInput: String = ""
#Namespace var namespace
// State variables from LegacyPostView
#State var user: User = exampleUsers[0]
#State var boujees: [LivePost] = []
#State var addBoujee: Bool = false
#State private var listener: ListenerRegistration?
#State private var scrollProxy: ScrollViewProxy? = nil
var textContent: some View {
ScrollView {
ScrollViewReader { proxy in
LazyVStack {
ForEach(boujees, id: \.self) { boujee in
GeometryReader { geo in
let midY = geo.frame(in: .named("scrollView")).midY
ZStack {
let height = (boujee.text.count / 40)
Text("**\(boujee.anonymous ? "anon".uppercased() : boujee.authorUsername.lowercased()):** \(boujee.text)")
.font(.title2)
.fontWeight(.heavy)
.blur(radius: 30 * (1 - (midY / 130)))
}
.padding(.horizontal, 20)
.opacity(midY / 130 > 1 ? 1 : midY / 130)
}
.frame(maxWidth: .infinity, minHeight: 150, maxHeight: 400)
}
}
.onAppear {
scrollProxy = proxy
// In the beginning scroll to bottom without animation.
scrollProxy?.scrollTo(boujees.last?.id)
}
.onTapGesture {
if profilePictureEnlarge {
withAnimation(.spring()) {
profilePictureEnlarge.toggle()
}
}
hideKeyboardOnTap()
}
}
}
.safeAreaInset(edge: .bottom, content: {
inputField
.offset(x: 0, y: profilePictureEnlarge ? 150 : 0)
})
.coordinateSpace(name: "scrollView")
}
I need the geometry reader to blur the text as it disappears out of the screen during scrolling. This messes up the height of each Text view like so
As you can see, there's unnecessary spacing in between text views that aren't as long as other views. SwiftUI handles this automatically but the GeometryReader messes up this auto-adjustment. This is because I set a minHeight value for the GeometryReader frame. I'd like to have text views adjust accoring to the line count, but I'm not sure how.

Part of scrollview should have scrolling disabled SwiftUI

I have a ScrollView layout as shown in photo.
Is it possible and how could you achieve the following in SwiftUI:
Green square area does not react to scroll gestures but is part of the ScrollView and will scroll with red squares
The rest of the screen (red square area) react to scroll gestures as usual.
The reason is ask this it that in green square area I am trying to add a SpriteView that will handle its own touch events and I don't want ScrollView to prevent this.
The only way I found is to create your own ScrollView, as shown below.
It might be easier to tackle with UIKit.
struct ContentView: View {
#State private var dragAmount = CGFloat.zero
#State private var dragPosition = CGFloat.zero
#State private var dragging = false
var body: some View {
GeometryReader { proxy in
VStack {
RoundedRectangle(cornerRadius: 20)
.fill(.red)
.frame(height: 300)
.overlay(
Text(dragging ? "dragging red ..." : "")
)
.offset(y: dragPosition + dragAmount)
.gesture(DragGesture()
.onChanged { _ in
dragging = true
}
.onEnded { _ in
dragging = false
}
)
ForEach(0..<20) { _ in
RoundedRectangle(cornerRadius: 20)
.fill(.green)
.frame(height: 100)
}
.offset(y: dragPosition + dragAmount)
.gesture(DragGesture()
.onChanged { value in
dragAmount = value.translation.height
}
.onEnded { value in
withAnimation(.easeOut) {
dragPosition += value.predictedEndTranslation.height
dragAmount = 0
dragPosition = min(0, dragPosition)
dragPosition = max(-1750, dragPosition)
}
}
)
}
}
}
}

Multiple animations do not work as expected in SwiftUI

I want to create a pulsing circle with forever repeating animation. It should have a gradual change of size and a momentary change of color when animation direction changes.
I have two different animations for that, but only the last one having an effect on both (size and color change).
struct SwiftUIView: View {
#State private var animationStarted = false
let side = UIScreen.main.bounds.size.width
let animation1 = Animation.easeInOut(duration: 0.3).delay(5.7)
.repeatForever(autoreverses: true)
let animation2 = Animation.easeInOut(duration: 6)
.repeatForever(autoreverses: true)
var body: some View {
HStack {
VStack {
HStack {
Circle()
.foregroundColor(self.animationStarted ? Color.pink : Color.blue)
.animation(animation1)
.frame(width: self.animationStarted ? 10 : side, height: self.animationStarted ? 10 : side)
.animation(animation2)
.onAppear {
self.animationStarted.toggle()
}
}
}.frame(height: side)
}.frame(width: side)
}
}

Why does my SwiftUI animation revert to the previous frame on rotation?

I have a SwiftUI View that is meant to be sort of a "warning" background that flashes on and off. It appears inside another View with a size that changes depending on device orientation. Everything works properly upon the first appearance, but if the device is rotated while the warning view is active it incorporates the frame of the previous orientation into the animation, making it not only flash on and off, but also move in and out of the view its positioned in, as well as change to and from the size it was on the previous orientation. Why is this happening and how can I fix it? Generic code:
struct PulsatingWarningBackground: View {
let color : Color
let speed : Double
#State private var opacity : Double = 1
var body: some View {
GeometryReader {geometry in
self.color
.opacity(self.opacity)
.animation(
Animation
.linear(duration: self.speed)
.repeatForever(autoreverses: true)
)
.onAppear(perform: {self.opacity = 0})
}
}
}
struct PulsatingWarningViewHolder: View {
var body: some View {
GeometryReader {geometry in
ZStack {
Color.white
ZStack {
Color.black
PulsatingWarningBackground(color: .red, speed: 1/4)
}.frame(width: geometry.frame(in: .local).width/5, height: geometry.frame(in: .local).height/10, alignment: .center)
}
}
}
}
You can apply animation only to opacity by using withAnimation(_:_:) inside onAppear(perform:). It works properly as you want.
struct PulsatingWarningBackground: View {
let color : Color
let speed : Double
#State private var opacity : Double = 1
var body: some View {
self.color
.opacity(self.opacity)
.onAppear(perform: {
withAnimation(Animation.linear(duration: self.speed).repeatForever()) {
self.opacity = 0
}
})
}
}
struct PulsatingWarningViewHolder: View {
var body: some View {
GeometryReader {geometry in
ZStack {
Color.white
ZStack {
Color.black
PulsatingWarningBackground(color: .red, speed: 1/4)
}
.frame(width: geometry.frame(in: .local).width/5, height: geometry.frame(in: .local).height/10, alignment: .center)
}
}
}
}

Anchor point in SwiftUI

I'm playing with SwiftUI animations. I wanted to make a square bigger modifying its frame with an animation this way:
struct ContentView: View {
let squareWidth = CGFloat(100)
let squareHeight = CGFloat(100)
#State private var bigger = false
var body: some View {
VStack {
Color.green
.frame(width: bigger ? self.squareWidth * 2 : self.squareWidth, height: self.squareHeight)
.animation(.default)
Button(action: {
self.bigger.toggle()
}) {
Text("Click me!")
}
}
}
}
The animation happens from the centre of the View, that is the anchor point (0.5, 0.5). Is there a way to change this anchor point? I found out that I can use scaleEffect on the view specifying an anchor:
struct ContentView: View {
let squareWidth = CGFloat(100)
let squareHeight = CGFloat(100)
#State private var bigger = false
var body: some View {
VStack {
Color.green
.frame(width: self.squareWidth, height: self.squareHeight)
.scaleEffect(x: bigger ? 2 : 1, y: 1, anchor: .leading)
.animation(.default)
Button(action: {
self.bigger.toggle()
}) {
Text("Click me!")
}
}
}
}
But it doesn't seem exactly the same. If I needed to apply several effects I should specify the anchor point for each of them, instead of specifying the anchor point on the view just one time. On UIKit I could write:
var myView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
myView.layer.anchorPoint = CGPoint(x: 0, y: 0.5) //that is, the .leading of SwiftUI
It is possible, but your approach must change.
There is an implied, built-in centring of views in SwiftUI, which can be confusing.
The parent view is the one placing the content in the centre, recalculating the position of its subviews each time their frames change.
Embedding the VStack in a HStack and adding borders to both clearly shows this behaviour.
Also, note the Spacer(), which pushes the view to the left.
HStack {
VStack {
Color.green
.frame(width: bigger ? self.squareWidth * 2 : self.squareWidth)
.frame(height: self.squareHeight)
.animation(.default)
Button("Click me!") { self.bigger.toggle() }
}.border(Color.black)
Spacer()
}.border(Color.red)
Using the .layer.anchorPoint in UIKit should have the same effect as using scaleEffect/offset/rotationEffect in SwiftUI, as it should affect the origin of the underlying geometry/texture which is being rendered by the GPU.
If you have a complex view (composed of multiple subviews) that needs the same scale effect, you can group them using a Group { } and use .scaleEffect on it.

Resources