SwiftUI: animate scrollview content offset? - ios

I have the following code:
struct TestItem:Identifiable {
var id = UUID()
var index:Int
}
struct ContentView: View {
#State var testItems = [TestItem]()
let itemsUpperBound:Int = 1000
#State var trigger = false
var columnGridItems = [
GridItem(.flexible()),
GridItem(.flexible())
]
var body: some View {
VStack {
HStack {
Button {
trigger.toggle()
print("trigger: \(trigger)")
} label: {
Text("TEST")
.foregroundColor(.white)
.padding()
.background(.blue)
}
Spacer()
}
ScrollView(.horizontal) {
ScrollViewReader { sr in
LazyHGrid(rows: columnGridItems) {
ForEach(testItems) { ti in
Text("id \(ti.id)")
}
}
.border(.blue)
.onChange(of: trigger) { newValue in
withAnimation(.easeInOut(duration: 10)) {
let idx = Int.random(in: 0..<testItems.count)
let id = testItems[idx].id
print("will try to scroll to id: \(id)")
sr.scrollTo(id)
}
}
}
}
.border(.red)
}
.onAppear {
for i in 0..<itemsUpperBound {
testItems.append(TestItem(index: i))
}
}
}
}
I would like to animate the scroll view content offset, with whatever duration I'd like to set, so that the app would show the entire content of the scrollview without requiring the user to scroll it. In UIKit I could do it by animating the content offset, but, I am not sure how to do this in SwiftUI.
How can I animate the content offset of a scrollview in SwiftUI to achieve this type of animation?
I have edited the code to account for Asperi's comment and tried to use a ScrollReader. It does scroll to the desired item in the scrollview, but the duration is not working. I set it to 10 seconds, and it doesn't seem to do anything.
How can I animate the scroll using a custom duration I want?

Related

How to move text and change it's value at the same time in SwiftUI?

For example, this is what happening right now
struct ContentView: View {
#State var titleLable = "This is basic text"
#State var isTextAnimated: Bool = false
var body: some View {
VStack {
Text(titleLable)
.offset(y: isTextAnimated ? 300 : 0)
.animation(.linear)
Button {
isTextAnimated.toggle()
if isTextAnimated {
titleLable = "New text appeared"
} else {
titleLable = "This is basic text"
}
} label: {
Text("Press")
}
}
.padding()
}
The code above leads to this in Live Preview:
click there
This happens if text doesn't change its value ( I need this behaviour with changing ):
click there
One of the simplest way to achieve this animation is to embed two Text inside a ZStackand modify their opacity, and modify the ZStack's offset rather than the individual Texts. in this way both the offset and the change between two texts will get animated. here is my code:
struct HomeScreen: View {
#State var isTextAnimated: Bool = false
var body: some View {
ZStack{
Text("Hello")
.opacity(isTextAnimated ? 1 : 0)
Text("World")
.opacity(isTextAnimated ? 0 : 1)
}
.offset(y: isTextAnimated ? 150 : 0)
Button(action: {withAnimation{isTextAnimated.toggle()}}){
Text("Press")
}
}
}
To animate the position and the content of the Text label, you can use matchedGeometryEffect, as follows:
struct ContentView: View {
#State var isTextAnimated: Bool = false
#Namespace var namespace
var body: some View {
VStack {
if isTextAnimated {
Text("New text appeared")
.matchedGeometryEffect(id: "title", in: namespace)
.offset(y: 300)
} else {
Text("This is basic text")
.matchedGeometryEffect(id: "title", in: namespace)
}
Button {
withAnimation {
isTextAnimated.toggle()
}
} label: {
Text("Press")
}
}
.padding()
}
}
edit: I forgot to animate the text change
struct AnimationsView: View {
#State private var buttonWasToggled = false
#Namespace private var titleAnimationNamespace
var body: some View {
VStack {
if !buttonWasToggled {
Text("This is some text")
.matchedGeometryEffect(id: "text", in: titleAnimationNamespace)
.transition(.opacity)
} else {
Text("Another text")
.matchedGeometryEffect(id: "text", in: titleAnimationNamespace)
.transition(.opacity)
.offset(y: 300)
}
Button("Press me") {
withAnimation {
buttonWasToggled.toggle()
}
}
}
}
}
A good way to animate such change is to animate the offset value rather than toggle a boolean:
struct AnimationsView: View {
#State private var title = "This is basic text"
#State private var offset: CGFloat = 0
var body: some View {
VStack {
Text("Some text")
.offset(y: offset)
Button("Press me") {
withAnimation {
// If we already have an offset, jump back to the previous position
offset = offset == 0 ? 300 : 0
}
}
}
}
}
or by using a boolean value:
struct AnimationsView: View {
#State private var title = "This is basic text"
#State private var animated = false
var body: some View {
VStack {
Text("Some text")
.offset(y: animated ? 300 : 0)
Button("Press me") {
withAnimation {
animated.toggle()
}
}
}
}
}
Note the important withAnimation that indicates to SwiftUI that you want to animate the changes made in the block. You can find the documentation here
The .animation(...) is optional and used if you want to change the behavior of the animation, such as using a spring, changing the speed, adding a delay etc... If you don't specify one, SwiftUI will use a default value. In a similar fashion, if you don't want a view to animate, you can use add the .animation(nil) modifier to prevent SwiftUI from animating said view.
Both solutions provided result in the following behavior : https://imgur.com/sOOsFJ0
As an alternative to .matchedGeometryEffect to animate moving and changing value of Text view you can "rasterize" text using .drawingGroup() modifier for Text. This makes text behave like shape, therefore animating smoothly. Additionally it's not necessary to define separate with linked with .machtedGeometryEffect modifier which can be impossible in certain situation. For example when new string value and position is not known beforehand.
Example
struct TextAnimation: View {
var titleLabel: String {
if self.isTextAnimated {
return "New text appeared"
} else {
return "This is basic text"
}
}
#State var isTextAnimated: Bool = false
var body: some View {
VStack {
Text(titleLabel)
.drawingGroup() // ⬅️ It makes text behave like shape.
.offset(y: isTextAnimated ? 100 : 0)
.animation(.easeInOut, value: self.isTextAnimated)
Button {
isTextAnimated.toggle()
} label: {
Text("Press")
}
}
.padding()
}
}
More informations
Apple's documentation about .drawingGroup modifier

How to prevent ScrollView to automatically show items added on top of inner ForEach

I'm building a bidirectional scrolling list of items and we need to add items to the head of the array, but the ScrollView is automatically scrolling down (probably to preserve the contentOffset?) and I can't achieve what I want, which would be that the items are ready to be displayed but only if the user scrolls up.
I'm not sure if there is a way to "fix" this by fiddling with ScrollView or if wrapping UIScrollView is just the easier way for now.
Snippet to reproduce:
struct ContentView: View {
#State var list = [1, 2, 3]
var body: some View {
VStack {
ScrollView {
LazyVStack {
ForEach(Array(zip(list.indices, list)), id: \.0) { index, element in
Text("\(element)")
}
}
}
.frame(maxHeight: 300)
Button(action: {
list = [Int.random(in: 1...1000)] + list
}) {
Text("Add")
.font(.title)
}
}
}
}
We can try this :
We need to read the offset of ScrollView's content. We'll use PreferenceKey.
private struct Offset: PreferenceKey {
static var defaultValue: CGPoint = .zero
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {}
}
We need to store the new elements (when user taps on "Add") until they are added (when user scrolls to the top) :
struct MutableScrollView: View {
#State private var list = Array(1 ... 100)
#State private var elementsToAdd = Array<Int>()
#State private var onTop: Bool = true
onClick is called when the user taps the Button. offsetChanged when we scroll.
func addNewElements() {
if onTop {
list = elementsToAdd + list
elementsToAdd.removeAll()
}
}
func onClick() {
elementsToAdd.append(Int.random(in: 100 ... 1000))
addNewElements()
}
func offsetChanged(_ offset: CGPoint) {
onTop = offset.y >= 0
addNewElements()
}
The View :
var body: some View {
VStack {
ScrollView {
GeometryReader { geometry in
Color.clear.preference(
key: Offset.self,
value: geometry.frame(in: .named("scrollView")).origin
)
}.frame(width: 0, height: 0)
LazyVStack {
ForEach(Array(zip(list.indices, list)), id: \.1) { _, element in
Text("\(element)")
}
}
}
.frame(maxHeight: 300)
.coordinateSpace(name: "scrollView")
.onPreferenceChange(Offset.self) { offsetChanged($0) }
Button(action: onClick) {
Text("Add")
.font(.title)
}
}
}
}

Save ScrollViews position and scroll back to it later (offset to position)

I have found a way to save a ScrollViews offset with a GeometryReader and a PreferenceKey.
SwiftUI | Get current scroll position from ScrollView
And the ScrollViewReader has a method scrollTo to scroll to a set position.
scrollTo
The problem is, that the first one saves an offset while the second method expects a position (or an id, which is similar to the position in my case). How can I convert the offset to a position/id or is there any other way to save and load a ScrollViews position?
Here is the code I have now but it does not scroll the way I want:
ScrollView {
ScrollViewReader { scrollView in
LazyVGrid(columns: columns, spacing: 0) {
ForEach(childObjects, id: \.id) { obj in
CustomView(obj: obj).id(obj.id)
}
}
.onChange(of: scrollTarget) { target in
if let target = target {
scrollTarget = nil
scrollView.scrollTo(target, anchor: .center)
}
}
.background(GeometryReader {
Color.clear.preference(key: ViewOffsetKey.self,
value: -$0.frame(in: .named("scroll")).origin.y)
})
.onPreferenceChange(ViewOffsetKey.self) { // save $0 }
}
}.coordinateSpace(name: "scroll")
And in the onAppear of the View I want to set scrollTarget to the saved position. But it scrolls anywhere but not to the position I want.
I thought about dividing the offset by the size of one item but is that really the way to go? It does not sound very good.
You don't need actually offset in this scenario, just store id of currently visible view (you can use any appropriate algorithm for your data of how to detect it) and then scroll to view with that id.
Here is a simplified demo of possible approach. Tested with Xcode 12.1/iOS 14.1
struct TestScrollBackView: View {
#State private var stored: Int = 0
#State private var current: [Int] = []
var body: some View {
ScrollViewReader { proxy in
VStack {
HStack {
Button("Store") {
// hard code is just for demo !!!
stored = current.sorted()[1] // 1st is out of screen by LazyVStack
print("!! stored \(stored)")
}
Button("Restore") {
proxy.scrollTo(stored, anchor: .top)
print("[x] restored \(stored)")
}
}
Divider()
ScrollView {
LazyVStack {
ForEach(0..<1000, id: \.self) { obj in
Text("Item: \(obj)")
.onAppear {
print(">> added \(obj)")
current.append(obj)
}
.onDisappear {
current.removeAll { $0 == obj }
print("<< removed \(obj)")
}.id(obj)
}
}
}
}
}
}
}

SwiftUI - Animate View expansion (show / hide)

I have a View that contains a HStack and a DatePicker. When you tap on the HStack, the DatePicker is shown / hidden. I want to animate this action like the animation of Starts and Ends row in iOS Calendar's New Event View.
struct TimePicker: View {
#Binding var startTime: Date
#State private var isDatePickerVisible: Bool = false
var body: some View {
VStack(alignment: .center) {
HStack {
ListItemView(icon: "start-time",
leadingText: "Start Time",
trailingText: startTime.stringTime())
}
.onTapGesture {
withAnimation {
self.isDatePickerVisible.toggle()
}
}
Group {
if isDatePickerVisible {
DatePicker("", selection: $startTime, displayedComponents: [.hourAndMinute])
.datePickerStyle(WheelDatePickerStyle())
}
}
.background(Color.red)
.modifier(AnimatingCellHeight(height: isDatePickerVisible ? 300 : 0))
}
}
}
I have used the following code for animation. It almost works. The only problem is that HStack jumps. And I can not fix it.
https://stackoverflow.com/a/60873883/8292178
struct AnimatingCellHeight: AnimatableModifier {
var height: CGFloat = 0
var animatableData: CGFloat {
get { height }
set { height = newValue }
}
func body(content: Content) -> some View {
content.frame(height: height)
}
}
How to fix this issue? How to animate visibility of the DatePicker?
It's simple, you don't need extra ViewModifier
struct TimePicker: View {
#Binding var startTime: Date
#State private var isDatePickerVisible: Bool = false
var body: some View {
VStack(alignment: .center) {
HStack {
ListItemView(icon: "start-time"
, leadingText: "Start Time"
, trailingText: startTime.stringTime())
}.onTapGesture {
isDatePickerVisible.toggle()
}
if isDatePickerVisible {
DatePicker(""
, selection: $model.startTime
, displayedComponents: [.hourAndMinute]
).datePickerStyle(WheelDatePickerStyle())
}
}.animation(.spring())
}
}

How make animations work inside ScrollView in SwiftUI? Accordion style animation in SwiftUI

I am trying to build a accordion style view with animations using SwiftUI. But Animations are not working properly if I wrap it into a ScrollView.Below is the code that I am trying to work out.
struct ParentView: View {
#State var isPressed = false
var body: some View {
GeometryReader { geometry in
ScrollView {
Group {
SampleView(index: 1)
SampleView(index: 2)
SampleView(index: 3)
SampleView(index: 4)
}.border(Color.black).frame(maxWidth: .infinity)
}.border(Color.green)
}
}
}
struct SampleView: View {
#State var index: Int
#State var isPressed = false
var body: some View {
Group {
HStack(alignment:.top) {
VStack(alignment: .leading) {
Text("********************")
Text("This View = \(index)")
Text("********************")
}
Spacer()
Button("Press") { self.isPressed.toggle() }
}
if isPressed {
VStack(alignment: .leading) {
Text("********************")
Text("-----> = \(index)")
Text("********************")
}.transition(.slide).animation(.linear).border(Color.red)
}
}
}
}
And Below is the screenshot of problem I'm facing.
To make it animate, wrap self.isPressed.toggle() in a withAnimation closure.
Button("Press") { withAnimation { self.isPressed.toggle() } }

Resources