SwiftUI and excessive redrawing - scroll

TL;DR:
Applying visual effects to the contents of a ScrollView causes thousands of requests for the same (unchanging) image for each drag gesture. Can I reduce this? (In my real app, I have 50-odd images in the view, and the scrolling is correspondingly sluggish.)
Gist
To give a little life to a scrolling HStack of images, I applied a few transforms for a circular "carousel" effect. (Tips of the hat to sample code from John M. and Paul Hudson)
The code is copy-paste-runnable as given. (You do need to provide an image.) Without the two lines marked /* 1 */ and /* 2 */ the Slide object reports six image requests, no matter how much you drag and scroll. Enable the two lines, and watch the request count zoom to 1000 with a single flick of your finger.
Remarks
SwiftUI is predicated on the inexpensive re-drawing of lightweight Views based on current state. Careless management of state dependency can improperly invalidate parts of the view tree. And in this case, the constant rotation and scaling while scrolling makes the runtime re-render the content.
But... should this necessarily require the continual re-retrieval of static images? Casual dragging back-and-forth of my little finger will trigger tens of thousands of image requests. This seems excessive. Is there a way to reduce the overhead in this example?
Of course this is a primitive design, which lays out all its contents all of the time, instead of taking the cell-reuse approach of, say, UITableView. One might think to apply the transformations only on the three currently-visible views. There is some discussion about this online, but in my attempts, the compiler couldn't do the type inference.
Code
import SwiftUI
// Comment out lines marked 1 & 2 and watch the request count go down.
struct ContentView: View {
var body: some View {
GeometryReader { outerGeo in
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(Slide.all) { slide in
GeometryReader { innerGeo in
Image(uiImage: slide.image).resizable().scaledToFit()
/* 1 */ .rotation3DEffect(.degrees(Double(innerGeo.localOffset(in: outerGeo).width) / 10), axis: (x: 0, y: 1, z: 0))
/* 2 */ .scaleEffect(1.0 - abs(innerGeo.localOffset(in: outerGeo).width) / 800.0)
}
.frame(width:200)
}
}
}
}
.clipped()
.border(Color.red, width: 4)
.frame(width: 400, height: 200)
}
}
// Provides images for the ScrollView. Tracks and reports image requests.
struct Slide : Identifiable {
let id: Int
static let all = (1...6).map(Self.init)
static var requestCount = 0
var image: UIImage {
Self.requestCount += 1
print("Request # \(Self.requestCount)")
return UIImage(named: "blueSquare")! // Or whatever image
}
}
// Handy extension for finding local coords.
extension GeometryProxy {
func localOffset(in outerGeo: GeometryProxy) -> CGSize {
let innerFrame = self.frame(in: .global)
let outerFrame = outerGeo.frame(in: .global)
return CGSize(
width : innerFrame.midX - outerFrame.midX,
height: innerFrame.midY - outerFrame.midY
)
}
}

i think you could try it like this:
no, it is not a full solution, i just took one cached image (instead of an array of cached images, which you have to preload beforehand) but the concept should be clear and so this should be fast...i think
class ImageCache {
static let slides = Slide.all
// do prefetch your images here....
static let cachedImage = UIImage(named: "blueSquare")!
struct Slide : Identifiable {
let id: Int
static let all = (1...6).map(Self.init)
static var requestCount = 0
var image: UIImage {
Self.requestCount += 1
print("Request # \(Self.requestCount)")
// return ImageCache.image! // Or whatever image
return ImageCache.cachedImage // Or whatever image
}
}
}
// Comment out lines marked 1 & 2 and watch the request count go down.
struct ContentView: View {
var body: some View {
GeometryReader { outerGeo in
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(ImageCache.slides) { slide in
GeometryReader { innerGeo in
Image(uiImage: slide.image).resizable().scaledToFit()
/* 1 */ .rotation3DEffect(.degrees(Double(innerGeo.localOffset(in: outerGeo).width) / 10), axis: (x: 0, y: 1, z: 0))
/* 2 */ .scaleEffect(1.0 - abs(innerGeo.localOffset(in: outerGeo).width) / 800.0)
}
.frame(width:200)
}
}
}
}
.clipped()
.border(Color.red, width: 4)
.frame(width: 400, height: 200)
}
}
// Provides images for the ScrollView. Tracks and reports image requests.
// Handy extension for finding local coords.
extension GeometryProxy {
func localOffset(in outerGeo: GeometryProxy) -> CGSize {
let innerFrame = self.frame(in: .global)
let outerFrame = outerGeo.frame(in: .global)
return CGSize(
width : innerFrame.midX - outerFrame.midX,
height: innerFrame.midY - outerFrame.midY
)
}
}

Related

SwiftUI Table rowHeight on macOS

Looking for some advice trying to use Table on macOS using SwiftUI. Table was introduced in macOS 12, and I'm trying my darnedest to not step down into AppKit or replicate any existing functionality - I can't seem to find a solution to a SwiftUI version of NSTableView's rowHeight property.
There is a .tableStyle modifier but only allows for customization of insets and alternating row styling. Modifying the frame in the row views doesn't take effect, at least not the ways I've tried.
First, am I missing something obvious (or not obvious) and there is a way to do this? The underlying AppKit view is a SwiftUITableView that seems to inherit to NSTableView. I can adjust the rowHieght in the debugger, but only effects the table view's background. Second any recommendations on the way to approach this - other than using NSTableView and wrapping in a NSViewRepresentable, or manipulating the established NSView hierarchy using some SwiftUI/AppKit trickery?
Some elided code demonstrating the use of Table
struct ContentTable: View {
var items: [ContentItem]
#State var selection = Set<ContentItem.ID>()
var body: some View {
Table(selection: $selection) {
TableColumn("Name") {
Text($0.name)
.frame(height: 80) // Only way found to set height information
}.width(min: 200, ideal: 250)
TableColumn("Description", value: \.description)
} rows: {
ForEach(items) {
TableRow($0)
}
}
.tableStyle(.inset(alternatesRowBackgrounds: true))
}
}
Here's a side-by-side of SF Symbols (Left) and the SwiftUI Table I'm using. Both are using the inset/alternating row styles. Presentation wise I'd like to give the rows more space to breathe.
The way I do this is by utilizing the padding modifier in the content of declared TableColumns.
The important part is the table will adjust the row by the lowest padding value across all columns.
I would not try to approach this with tableStyle. There are no public configurations as of now.
It's important to note an undeclared padding defaults to 0.
struct ContentTable: View {
var items: [ContentItem]
#State var selection = Set<ContentItem.ID>()
var body: some View {
Table(selection: $selection) {
TableColumn("Name") {
Text($0.name).padding(.vertical, 8) // <--- THIS WILL TAKE PRECEDENCE.
}.width(min: 200, ideal: 250)
TableColumn("Description") {
Text("\($0.description)").padding(.vertical, 16) // <--- THIS WILL BE INEFFECTIVE.
}
} rows: {
ForEach(items) {
TableRow($0)
}
}
.tableStyle(.inset(alternatesRowBackgrounds: true))
}
}

SwiftUI animation - toggled Boolean always ends up as true

I'm trying to create an animation in my app when a particular action happens which will essentially make the background of a given element change colour and back x number of times to create a kind of 'pulse' effect. The application itself is quite large, but I've managed to re-create the issue in a very basic app.
So the ContentView is as follows:
struct ContentView: View {
struct Constants {
static let animationDuration = 1.0
static let backgroundAlpha: CGFloat = 0.6
}
#State var isAnimating = false
#ObservedObject var viewModel = ContentViewViewModel()
private let animation = Animation.easeInOut(duration: Constants.animationDuration).repeatCount(6, autoreverses: false)
var body: some View {
VStack {
Text("Hello, world!")
.padding()
Button(action: {
animate()
}) {
Text("Button")
.foregroundColor(Color.white)
}
}
.background(isAnimating ? Color.red : Color.blue)
.onReceive(viewModel.$shouldAnimate, perform: { _ in
if viewModel.shouldAnimate {
withAnimation(self.animation, {
self.isAnimating.toggle()
})
}
})
}
func animate() {
self.viewModel.isNew = true
}
}
And then my viewModel is:
import Combine
import SwiftUI
class ContentViewViewModel: ObservableObject {
#Published var shouldAnimate = false
#Published var isNew = false
var cancellables = Set<AnyCancellable>()
init() {
$isNew
.sink { result in
if result {
self.shouldAnimate = true
}
}
.store(in: &cancellables)
}
}
So the logic I am following is that when the button is tapped, we set 'isNew' to true. This in turn is a publisher which, when set to true, sets 'shouldAnimate' to true. In the ContentView, when shouldAnimate is received and is true, we toggle the background colour of the VStack x number of times.
The reason I am using this 'shouldAnimate' published property is because in the actual app, there are several different actions which may need to trigger the animation, and so it feels simpler to have this tied to one variable which we can listen for in the ContentView.
So in the code above, we should be toggling the isAnimating bool 6 times. So, we start with false then toggle as follows:
1: true, 2: false, 3: true, 4: false, 5: true, 6: false
So I would expect to end up on false and therefore have the background white. However, this is what I am getting:
I tried changing the repeatCount (in case I was misunderstanding how the count works):
private let animation = Animation.easeInOut(duration: Constants.animationDuration).repeatCount(7, autoreverses: false)
And I get the following:
No matter the count, I always end on true.
Update:
I have now managed to get the effect I am looking for by using the following loop:
for i in 0...5 {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i), execute: {
withAnimation(self.animation, {
self.isAnimating.toggle()
})
})
}
Not sure this is the best way to go though....
To understand what is going on, it would help to understand CALayer property animations.
When you define an animation the system captures the state of a Layer and watches for changes in the animatable properties of that layer. It records property changes for playback during the animation. To present the animation, it create a copy of the layer in its initial state (the presentationLayer). It then substitutes the copy in place of the actual layers on screen and runs the animation by manipulating the animatable properties of the presentation layer.
I this case, when you begin the animation, the system watches what happens to the CALayer that backs your view and captures the changes to any animatable properties (in this case the background color). It then creates a presentationLayer and replays those property changes repeatedly. It's not running your code repeatedly - it's changing the properties of the presentation Layer.
In other words the animation the system knows the layer's background color property should toggle back and forth because of the example you set in your animation block, but the animation toggles the background color back and forth without running your code again.

Simple SwiftUI transition that doesn't seem to be working

I've been trying to get a comprehensive understanding of how Animations and Transitions work in SwiftUI.
I've been experimenting with different transitions and animations all day but one transition I want isn't working. I'll first show the code and then explain what sort of transition I want.
struct Test: View {
#State private var pressed = false // Controls whether the tower is shown or not.
var body: some View {
VStack {
Button(pressed ? "Press me to hide tower" : "Press me to show tower") { // Controls truth value of the "pressed" variable above.
withAnimation(.easeInOut(duration: 5)) { // I've set the duration to 5 because I want to see the animation in slow-motion.
self.pressed.toggle() // Toggles truth value of "pressed" from true to false or vice-versa.
}
}
if pressed { // Displays the Tower when "pressed" is true.
Tower() // Tower struct is provided below.
}
}
}
}
And this is the Tower struct:
struct Tower: View {
var body: some View {
VStack {
Text("Level 3").transition(.move(edge: .leading))
Text("Level 2")
Text("Level 1").transition(.move(edge: .trailing))
}
}
}
The transition I want to achieve is pretty straightforward - I want Level 3 to fly in from the left, Level 1 to fly in from the right, and Level 2 to just fade in and out. With this code however, Levels 1, 2 and 3, all just fade in and out together. The .move(edge: .trailing) transition seems to not work for some reason.
The catch is that I definitely want the Tower struct and the Test struct to be separate at all times. (I don't want to copy-paste any of the code that's within the Tower struct inside of the Test struct)
If you can show me how I can make the upper and lower levels fly in from different sides please let me know (if you can provide a code sample as well it'll help a ton).
Transition is an engine to present/remove a view in/from view hierarchy (with animation if animation is specified). It is applied to view as a whole, directly, and is not passed-into view's subviews. So if you try to add view into view hierarchy that does not have own transition it just appears, immediately, if there is animation then by default fade-in/out transition is applied (again, to view as a whole).
But you want to transition view's internals from outside. So here is possible solution.
Tested with Xcode 11.4 / iOS 13.4 (you can play with animations by yourself)
struct Test: View {
#State private var pressed = false
var body: some View {
VStack {
Button(pressed ? "Press me to hide tower" : "Press me to show tower") {
self.pressed.toggle()
}
Tower(show: $pressed)
}.animation(.easeInOut)
}
}
struct Tower: View {
#Binding var show: Bool
var body: some View {
VStack {
if show {
Text("Level 3").transition(.move(edge: .leading))
Text("Level 2")
Text("Level 1").transition(.move(edge: .trailing))
}
}
.animation(.easeInOut)
}
}

Animating Text in SwiftUI

SwiftUI has wonderful animation features, but the way it handles changes in Text View content is problematic. It animates the change of the text frame but changes the text immediately without animation. As a result, when the content of a Text View is made longer, animating the transition causes an ellipsis (…) to appear until the text frame reaches its full width. For example, in this little app, pressing the Toggle button switches between shorter and longer text:
Here's the code:
import SwiftUI
struct ContentView: View {
#State var shortString = true
var body: some View {
VStack {
Text(shortString ? "This is short." : "This is considerably longer.").font(.title)
.animation(.easeInOut(duration:1.0))
Button(action: {self.shortString.toggle()}) {
Text("Toggle").padding()
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
The question is: how to avoid the ellipsis? When animating a one character string into a two character string the situation is even worse, because the short string is completely replaced by the ellipsis while it animates into the longer string.
One possibility is to assign a separate id to the view in one state or another by adding the modifier, for instance, .id(self.shortString ? 0 : 1) and then adding a .transition() modifier. That will treat the Text as two different Views, before and after. Unfortunately, in my case I need to move text location during the change, and different ids makes animating that impossible.
I guess the solution is a creative use of AnimatableData. Any ideas?
Here is a demo of possible approach (scratchy - you can redesign it to extension, modifier, or separate view)
Tested with Xcode 11.4 / iOS 13.4
struct ContentView: View {
#State var shortString = true
var body: some View {
VStack {
if shortString {
Text("This is short.").font(.title).fixedSize()
.transition(AnyTransition.opacity.animation(.easeInOut(duration:1.0)))
}
if !shortString {
Text("This is considerably longer.").font(.title).fixedSize()
.transition(AnyTransition.opacity.animation(.easeInOut(duration:1.0)))
}
Button(action: {self.shortString.toggle()}) {
Text("Toggle").padding()
}
}
}
}
Any suggestions for shrinking an animated gif's dimensions?
I use this way:
- decrease zoom of Preview to 75% (or resize window of Simulator)
- use QuickTimePlayer region-based Screen Recording
- use https://ezgif.com/video-to-gif for converting to GIF
If you add .animation(nil) to the Text object definition then the contents will change directly between values, avoiding ellipsis.
However, this may prevent the animation of the text location, which you also mention wanting to do simultaneously.
You can add one by one character into a string with animation after 0.1 seconds additional, but remember to disable the button toggle while the characters being added, like below:
Code:
public struct TextAnimation: View {
public init(){ }
#State var text: String = ""
#State var toggle = false
public var body: some View {
VStack{
Text(text).animation(.spring())
HStack {
Button {
toggle.toggle()
} label: {
Text("Toggle")
}
}.padding()
}.onChange(of: toggle) { toggle in
if toggle {
text = ""
"This is considerably longer.".enumerated().forEach { index, character in
DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 0.1) {
text += String(character)
}
}
} else {
text = "This is short."
}
}
}
}

SwiftUI - How to change hierarchical position of View?

Here's a basic example of what doesn't work:
import SwiftUI
struct Test : View {
#State var swapped = false
var body: some View {
if swapped { Color.green }
Color.blue.tapAction {
withAnimation { self.swapped.toggle() }
}
if !swapped { Color.green }
}
}
SwiftUI has no way to figure out that I think of the first Color.green and the second Color.green as the same view, so of course the animation just fades one of them out while fading the other one in at the new location. I'm looking for the way to indicate to SwiftUI that these are the same view, and to animate it to the new location. I discovered the .id() modifier with great excitement because I believed that it would give me the effect that I want:
import SwiftUI
struct Test : View {
#State var swapped = false
var body: some View {
if swapped { Color.green.id("green") }
Color.blue.tapAction {
withAnimation { self.swapped.toggle() }
}
if !swapped { Color.green.id("green") }
}
}
This, unfortunately, does not work either. I'm unbelievably excited about SwiftUI, but it seems to me that the ability to change the structure of the view hierarchy while preserving view identity is quite important. The actual use case which prompted me to think about this is that I have a handful of views which I'm trying to create a fan animation for. The simplest way would be to have the items in a ZStack in one state so that they are all on top of each other, and then to have them in a VStack in the fanned out state, so that they're vertically spread out and all visible. Changing from a ZStack to a VStack of course counts as a change to the structure and therefore all continuity between the states is lost and everything just cross fades. Does anyone know what to do about this?
You can do this with SwiftUI 2 introduced in iOS 14 / macOS 10.16:
#Namespace private var animation
…
MyView.matchedGeometryEffect(id: "myID", in: animation)
I think I have the animation part of your question down, but unfortunately, I don't think it will keep the same instance of the view. I tried taking green out into a variable to see if that works, but if I understand SwiftUI correctly, that doesn't mean the same instance of the view will be shared in two places.
What I'm doing is adding a transition when the green view is added/removed. This way, the view moves to the location of its replacement before disappearing.
struct Test : View {
#State var swapped = false
let green = Color.green
var body: some View {
HStack {
if swapped {
green
.transition(.offset(CGSize(width: 200, height: 0)))
.animation(.basic())
}
Color.blue.animation(.basic()).tapAction {
withAnimation { self.swapped.toggle() }
}
if !swapped {
green.transition(.offset(CGSize(width: -200, height: 0)))
.animation(.basic())
}
}
}
}
This solution is quick-and-dirty and uses hard-coded values based on the iPhone 6/7/8 portrait screen size

Resources