How do I animate exchanging one image for another in SwiftUI? - animation

I am exchanging one image for another in a view as a result of a tap, double-tap, or drag gesture, and I would like to animate the transition between images. As shown below, I'm using withAnimation within onEnded for both the tapDouble and the horizontal drag gestures. (The animation is omitted from the other gestures for comparison.) In neither case does refreshing the view behave differently from the unanimated gestures. I would appreciate help in figuring out how to animate the replacement of an image.
struct PieceView: View {
var id: String
var ext: String
#State private var image: Image
var drag: some Gesture {
DragGesture()
.onEnded { gesture in
if abs(gesture.translation.width) > abs(gesture.translation.height) {
withAnimation(.easeIn(duration: 2.0)) {
image = Image(id + "4")
}
flipHorizontal()
} else {
image = Image(id + "6")
flipVertical()
}
}
}
var tapSingle: some Gesture {
TapGesture()
.onEnded{_ in
image = Image(id + "1")
rotateRight()
}
}
var tapDouble: some Gesture {
TapGesture(count: 2)
.onEnded{_ in
withAnimation(.easeIn(duration: 2.0)) {
image = Image(id + "3")
}
rotateLeft()
}
}
var body: some View {
ZStack {
image
.gesture(drag)
Image("emptySquare")
.padding(0)
.gesture(tapSingle)
.highPriorityGesture(tapDouble)
}
}
init() {
id = ""
ext = ""
image = Image("")
}
init(id: String, ext: String = "0") {
self.id = id
self.ext = ext
self.image = Image(id + ext)
}
func rotateRight() -> () {
print ("\(id) rotated right")
}
func rotateLeft() -> () {
print ("\(id) rotated left")
}
func flipHorizontal() -> () {
print ("\(id) flipped horizontal")
}
func flipVertical() -> () {
print ("\(id) flipped vertical")
}
}

Related

Swift UI - animation - terrible performances

I'm trying to display an automatic scrolling text (marquee?) with an animation using Swift UI.
When the mouse is over the text, the animation stops (that's why I store the current state of the animation).
Using one of the latest M1 MBP, this simple animation is using up to 10% of CPU and I'm trying to understand why. Is Swift UI not made for animations like this one or am I doing something wrong? At the end, it's just an animation moving the x offset.
Here is the code of my Marquee.
import SwiftUI
private enum MarqueeState {
case idle
case animating
}
struct GeometryBackground: View {
var body: some View {
GeometryReader { geometry in
Color.clear.preference(key: WidthKey.self, value: geometry.size.width)
}
}
}
struct WidthKey: PreferenceKey {
static var defaultValue = CGFloat(0)
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
typealias Value = CGFloat
}
extension View {
func myOffset(x: CGFloat, y: CGFloat) -> some View {
return modifier(_OffsetEffect(offset: CGSize(width: x, height: y)))
}
func myOffset(_ offset: CGSize) -> some View {
return modifier(_OffsetEffect(offset: offset))
}
}
struct PausableOffsetX: GeometryEffect {
#Binding var currentOffset: CGFloat
#Binding var contentWidth: CGFloat
private var targetOffset: CGFloat = 0.0;
var animatableData: CGFloat {
get { targetOffset }
set { targetOffset = newValue }
}
init(targetOffset: CGFloat, currentOffset: Binding<CGFloat>, contentWidth: Binding<CGFloat>) {
self.targetOffset = targetOffset
self._currentOffset = currentOffset
self._contentWidth = contentWidth
}
public func effectValue(size: CGSize) -> ProjectionTransform {
DispatchQueue.main.async {
self.currentOffset = targetOffset
}
let relativeOffset = targetOffset.truncatingRemainder(dividingBy: contentWidth)
let transform = CGAffineTransform(translationX: relativeOffset, y: 0)
return ProjectionTransform(transform)
}
}
struct Marquee<Content: View> : View {
#State private var isOver: Bool = false
private var content: () -> Content
#State private var state: MarqueeState = .idle
#State private var contentWidth: CGFloat = 0
#State private var isAppear = false
#State private var targetOffsetX: CGFloat = 0
#State private var currentOffsetX: CGFloat
public init(#ViewBuilder content: #escaping () -> Content) {
self.content = content
self.currentOffsetX = 0
}
private func getAnimation() -> Animation {
let duration = contentWidth / 30
print("animation with duration of ", duration)
return Animation.linear(duration: duration).repeatForever(autoreverses: false);
}
public var body : some View {
GeometryReader { proxy in
HStack(alignment: .center, spacing: 0) {
if isAppear {
content()
.overlay(GeometryBackground())
.fixedSize()
content()
.overlay(GeometryBackground())
.fixedSize()
}
}
.modifier(PausableOffsetX(targetOffset: targetOffsetX, currentOffset: $currentOffsetX, contentWidth: $contentWidth))
.onPreferenceChange(WidthKey.self, perform: { value in
if value != self.contentWidth {
self.contentWidth = value
print("Content width = \(value)")
resetAnimation()
}
})
.onAppear {
self.isAppear = true
resetAnimation()
}
.onDisappear {
self.isAppear = false
}
.onHover(perform: { isOver in
self.isOver = isOver
checkAnimation()
})
}
.frame(width: 400)
.clipped()
}
private func getOffsetX() -> CGFloat {
switch self.state {
case .idle:
return self.currentOffsetX
case .animating:
return -self.contentWidth + currentOffsetX
}
}
private func checkAnimation() {
if isOver{
if self.state != .idle {
pauseAnimation()
}
} else {
if self.state != .animating {
resumeAnimation()
}
}
}
private func pauseAnimation() {
withAnimation(.linear(duration: 0)) {
self.state = .idle
self.targetOffsetX = getOffsetX()
}
}
private func resumeAnimation() {
print("Resume animation");
withAnimation(getAnimation()) {
self.state = .animating
self.targetOffsetX = getOffsetX()
}
}
private func resetAnimation() {
withAnimation(.linear(duration: 0)) {
self.currentOffsetX = 0
self.targetOffsetX = 0
self.state = .idle
}
resumeAnimation()
}
}
And we can use it as follow:
Marquee {
Text("Hello, world! Hello, world! Hello, world! Hello, world!").padding().fixedSize()
}.frame(width: 300)
EDIT
I ended up using Core Animation instead of the one built in Swift UI. The cpu / Energy impact is an absolute zero. So I wouldn't recommend using Swift UI animation for long lasting or persistant animations.

Sliding new image in SwiftUI

SWIFTUI - ANIMATED TRANSITIONS
I want to slide a new image from right to left when I click the next button and from left to right when I click the previous button. The sliding should be animated. Here is the code:
import SwiftUI
struct DetailView: View {
#State private var image: Image?
#State private var index: Int = 1
var body: some View {
VStack {
image?
.resizable()
.scaledToFit()
.transition(.slide)
.animation(.default)
}
.onAppear(perform:loadImage)
Button("Next", action: {
index += 1
loadImage()
})
Button("Previous", action: {
index -= 1
loadImage()
})
}
func loadImage() {
let strname = String(format: "image%02d", index)
image = Image (strname)
}
struct DetailView_Previews: PreviewProvider {
static var previews: some View {
DetailView()
}
}
}
You can attach an id to the image so that whenever you update the image, SwiftUI sees 2 different views — making it animate the transition.
Edit: use the move transition to control direction.
struct ContentView: View {
#State private var image: Image?
#State private var index: Int = 1
#State private var forwards = false
var body: some View {
VStack {
image?
.resizable()
.scaledToFit()
.transition(
.asymmetric(
insertion: .move(edge: forwards ? .trailing : .leading),
removal: .move(edge: forwards ? .leading : .trailing)
)
)
.id(UUID())
Button("Next") {
index += 1
forwards = true
withAnimation {
loadImage()
}
}
Button("Previous") {
index -= 1
forwards = false
withAnimation {
loadImage()
}
}
}
.onAppear(perform: loadImage)
}
func loadImage() {
let name = String(format: "image%02d", index)
image = Image(name)
}
}
Result:

swiftui, animation applied to parent effect child animation(Part II)

PREVIOUS Q: swiftui, animation applied to parent effect child animation
Now the TextView has its own state. RectangleView with TextView slide into screen in 3 seconds, but state of TextView changed after a second while sliding. Now you can see TextView stops sliding, go to the destination at once. I Just want RectangleView with TextView as a whole when sliding, and TextView rotates or keeps still as I want.
import SwiftUI
struct RotationEnvironmentKey: EnvironmentKey {
static let defaultValue: Bool = false
}
extension EnvironmentValues {
var rotation: Bool {
get { return self[RotationEnvironmentKey] }
set { self[RotationEnvironmentKey] = newValue }
}
}
struct AnimationTestView: View {
#State private var go = false
#State private var rotation = false
var body: some View {
VStack {
Button("Go!") {
go.toggle()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
rotation.toggle()
print("set rotation = \(rotation)")
}
}
Group {
if go {
RectangleView()
.transition(.slide)
.environment(\.rotation, rotation)
}
}.animation(.easeInOut(duration: 3.0), value: go)
}.navigationTitle("Animation Test")
}
}
struct RectangleView: View {
var body: some View {
Rectangle()
.frame(width: 200, height: 100)
.foregroundColor(.pink)
.overlay(TextView())
}
}
struct TextView: View {
#Environment(\.rotation) var rotation
#State private var animationRotating: Bool = false
let animation = Animation.linear(duration: 3.0).repeatForever(autoreverses: false)
var body: some View {
print("refresh, rotation = \(rotation)"); return
HStack {
Spacer()
if rotation {
Text("R")
.foregroundColor(.blue)
.rotationEffect(.degrees(animationRotating ? 360 : 0))
.animation(animation, value: animationRotating)
.onAppear { animationRotating = true }
.onDisappear { animationRotating = false }
} else {
Text("S")
}
}
}
}
I found the issue here: cannot use if cause in TextView, because when rotation changed, will create a new Text in new position, so we can see the R 'jump' to the right position.
code as following:
Text(rotation ? "R" : "S")
.foregroundColor(rotation ? .blue : .white)
.rotationEffect(.degrees(animationRotating ? 360 : 0))
.animation(rotation ? animation : nil, value: animationRotating)
.onChange(of: rotation) { newValue in
animationRotating = true
}

how to do a simple scaling animation and why isn't this working?

i just read in stackoverflow i can only concatenate animation with delay, so i tried this here which simply shrinks and then scales the circle again. unfortunately the shrinking doesn't work!? if i comment out the growing, shrinking works...
struct ContentView: View {
#State var scaleImage : CGFloat = 1
var body: some View {
VStack {
Button(action: {
withAnimation(Animation.easeInOut(duration: 1)) {
self.scaleImage = 0.01
}
withAnimation(Animation.easeInOut(duration: 1).delay(1.0)) {
self.scaleImage = 1
}
}) {
Text ("Start animation")
}
Image(systemName: "circle.fill")
.scaleEffect(scaleImage)
}
}
}
Here is possible approach (based on AnimatableModifier). Actually it demonstrates how current animation end can be detected, and performed something - in this case, for your scaling scenario, just initiate reversing.
Simplified & modified your example
struct TestReversingScaleAnimation: View {
#State var scaleImage : CGFloat = 1
var body: some View {
VStack {
Button("Start animation") {
self.scaleImage = 0.01 // initiate animation
}
Image(systemName: "circle.fill")
.modifier(ReversingScale(to: scaleImage) {
self.scaleImage = 1 // reverse set
})
.animation(.default) // now can be implicit
}
}
}
Actually, show-maker here... important comments inline.
Updated for Xcode 13.3 (tested with iOS 15.4)
struct ReversingScale: AnimatableModifier {
var value: CGFloat
private let target: CGFloat
private let onEnded: () -> ()
init(to value: CGFloat, onEnded: #escaping () -> () = {}) {
self.target = value
self.value = value
self.onEnded = onEnded // << callback
}
var animatableData: CGFloat {
get { value }
set { value = newValue
// newValue here is interpolating by engine, so changing
// from previous to initially set, so when they got equal
// animation ended
let callback = onEnded
if newValue == target {
DispatchQueue.main.async(execute: callback)
}
}
}
func body(content: Content) -> some View {
content.scaleEffect(value)
}
}
Original variant (tested with Xcode 11.4 / iOS 13.4)
struct ReversingScale: AnimatableModifier {
var value: CGFloat
private var target: CGFloat
private var onEnded: () -> ()
init(to value: CGFloat, onEnded: #escaping () -> () = {}) {
self.target = value
self.value = value
self.onEnded = onEnded // << callback
}
var animatableData: CGFloat {
get { value }
set { value = newValue
// newValue here is interpolating by engine, so changing
// from previous to initially set, so when they got equal
// animation ended
if newValue == target {
onEnded()
}
}
}
func body(content: Content) -> some View {
content.scaleEffect(value)
}
}

Scroll SwiftUI List to new selection

If you have a SwiftUI List with that allows single selection, you can change the selection by clicking the list (presumably this makes it the key responder) and then using the arrow keys. If that selection reaches the end of the visible area, it will scroll the whole list to keep the selection visible.
However, if the selection object is updated in some other way (e.g. using a button), the list will not be scrolled.
Is there any way to force the list to scroll to the new selection when set programmatically?
Example app:
import SwiftUI
struct ContentView: View {
#State var selection: Int? = 0
func changeSelection(_ by: Int) {
switch self.selection {
case .none:
self.selection = 0
case .some(let sel):
self.selection = max(min(sel + by, 20), 0)
}
}
var body: some View {
HStack {
List((0...20), selection: $selection) {
Text(String($0))
}
VStack {
Button(action: { self.changeSelection(-1) }) {
Text("Move Up")
}
Button(action: { self.changeSelection(1) }) {
Text("Move Down")
}
}
}
}
}
I tried several solutions, one of them I'm using in my project (I need horizontal paging for 3 lists). And here are my observations:
I didn't find any methods to scroll List in SwiftUI, there is no mention about it in documentation yet;
You may try ScrollView (my variant below, here is other solution), but these things might look monstroid;
Maybe the best way is to use UITableView: tutorial from Apple and try scrollToRowAtIndexPath method (like in this answer).
As I wrote, here is my example, which, of course, requires refinement. First of all ScrollView needs to be inside GeometryReader and you can understand the real size of content. The second thing is that you need to control your gestures, which might be difficult. And the last one: you need to calculate current offset of ScrollViews's content and it could be other than in my code (remember, I tried to give you example):
struct ScrollListView: View {
#State var selection: Int?
#State private var offset: CGFloat = 0
#State private var isGestureActive: Bool = false
func changeSelection(_ by: Int) {
switch self.selection {
case .none:
self.selection = 0
case .some(let sel):
self.selection = max(min(sel + by, 30), 0)
}
}
var body: some View {
HStack {
GeometryReader { geometry in
VStack {
ScrollView(.vertical, showsIndicators: false) {
ForEach(0...29, id: \.self) { line in
ListRow(line: line, selection: self.$selection)
.frame(height: 20)
}
}
.content.offset(y: self.isGestureActive ? self.offset : geometry.size.height / 4 - CGFloat((self.selection ?? 0) * 20))
.gesture(DragGesture()
.onChanged({ value in
self.isGestureActive = true
self.offset = value.translation.width + -geometry.size.width * CGFloat(self.selection ?? 1)
})
.onEnded({ value in
DispatchQueue.main.async { self.isGestureActive = false }
}))
}
}
VStack {
Button(action: { self.changeSelection(-1) }) {
Text("Move Up")
}
Spacer()
Button(action: { self.changeSelection(1) }) {
Text("Move Down")
}
}
}
}
}
of course you need to create your own "list row":
struct ListRow: View {
#State var line: Int
#Binding var selection: Int?
var body: some View {
HStack(alignment: .center, spacing: 2){
Image(systemName: line == self.selection ? "checkmark.square" : "square")
.padding(.horizontal, 3)
Text(String(line))
Spacer()
}
.onTapGesture {
self.selection = self.selection == self.line ? nil : self.line
}
}
}
hope it'll be helpful.
In the new relase of SwiftUI for iOs 14 and MacOs Big Sur they added the ability to programmatically scroll to a specific cell using the new ScrollViewReader:
struct ContentView: View {
let colors: [Color] = [.red, .green, .blue]
var body: some View {
ScrollView {
ScrollViewReader { value in
Button("Jump to #8") {
value.scrollTo(8)
}
ForEach(0..<10) { i in
Text("Example \(i)")
.frame(width: 300, height: 300)
.background(colors[i % colors.count])
.id(i)
}
}
}
}
}
Then you can use the method .scrollTo() like this
value.scrollTo(8, anchor: .top)
Credit: www.hackingwithswift.com
I am doing it this way:
1) Reusable copy-paste component:
import SwiftUI
struct TableViewConfigurator: UIViewControllerRepresentable {
var configure: (UITableView) -> Void = { _ in }
func makeUIViewController(context: UIViewControllerRepresentableContext<TableViewConfigurator>) -> UIViewController {
UIViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: UIViewControllerRepresentableContext<TableViewConfigurator>) {
//let tableViews = uiViewController.navigationController?.topViewController?.view.subviews(ofType: UITableView.self) ?? [UITableView]()
let tableViews = UIApplication.nonModalTopViewController()?.navigationController?.topViewController?.view.subviews(ofType: UITableView.self) ?? [UITableView]()
for tableView in tableViews {
self.configure(tableView)
}
}
}
2) Extension on UIApplication to find top view controller in hierarchy
extension UIApplication {
class var activeSceneRootViewController: UIViewController? {
if #available(iOS 13.0, *) {
for scene in UIApplication.shared.connectedScenes {
if scene.activationState == .foregroundActive {
return ((scene as? UIWindowScene)?.delegate as? UIWindowSceneDelegate)?.window??.rootViewController
}
}
} else {
// Fallback on earlier versions
return UIApplication.shared.keyWindow?.rootViewController
}
return nil
}
class func nonModalTopViewController(controller: UIViewController? = UIApplication.activeSceneRootViewController) -> UIViewController? {
print(controller ?? "nil")
if let navigationController = controller as? UINavigationController {
return nonModalTopViewController(controller: navigationController.topViewController ?? navigationController.visibleViewController)
}
if let tabController = controller as? UITabBarController {
if let selected = tabController.selectedViewController {
return nonModalTopViewController(controller: selected)
}
}
if let presented = controller?.presentedViewController {
let top = nonModalTopViewController(controller: presented)
if top == presented { // just modal
return controller
} else {
print("Top:", top ?? "nil")
return top
}
}
if let navigationController = controller?.children.first as? UINavigationController {
return nonModalTopViewController(controller: navigationController.topViewController ?? navigationController.visibleViewController)
}
return controller
}
}
3) Custom part - Here you implement your solution for UITableView behind List like scrolling:
Use it like modifier on any view in List in View
.background(TableViewConfigurator(configure: { tableView in
if self.viewModel.statusChangeMessage != nil {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) {
let lastIndexPath = IndexPath(row: tableView.numberOfRows(inSection: 0) - 1, section: 0)
tableView.scrollToRow(at: lastIndexPath, at: .bottom, animated: true)
}
}
}))

Resources