4

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
    )
  }
}
Andrew Duncan
  • 3,553
  • 4
  • 28
  • 55
  • silly question: why is your image retrieval expensive? if you cache it, it should be blazingly fast... – Chris Apr 17 '20 at 15:53
  • 1
    I did do that, and it helped. Just not ennough. – Andrew Duncan Apr 17 '20 at 17:17
  • To add to that: your idea is right for many cases. In my current project I have (so far) five different caches for expensive assets. E.g. all my static datasets (paths, coordinates, layouts) are normalized to a containing rect of size 1x1. I cache the denormalized values since they won't change during runtime. But it often seems like I'm defending myself against SwiftUI instead of going with the flow. – Andrew Duncan Apr 18 '20 at 20:40

1 Answers1

0

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
    )
  }
}
Chris
  • 7,579
  • 3
  • 18
  • 38
  • 4
    This doesn't really answer my question, which wasn't "How can I speed up image retrieval?" but "How can I reduce unnecessary image retrieval?" – Andrew Duncan Apr 17 '20 at 23:05
  • this article (halfway down) explains well the use of view id(_) for improving view performance. Maybe this will help in your situation. https://swiftui-lab.com/swiftui-id/ – workingdog support Ukraine Apr 17 '20 at 23:46
  • That was interesting, thank you workingdog. The challenge is where to place the update to the id value? In my code example, there is no place where the flow of control is exposed. It's purely declarative, which should let me forget all this-before-that stuff. Maybe if I break out the drag handler... btw I did try slotting in some `.id` modifiers anyway, to no avail. Maybe this just highlights the advantages of pre-fetching and data sources, like the "old" days of `UITableView`. I see that thousands of operations have to be performed, but not thousands of re-fetches. – Andrew Duncan Apr 18 '20 at 03:31
  • 1
    Although I believe SwiftUI (or I) should (will?) be smarter about this, the perceptible delays were unacceptable. I wrote the same code for a `UIScrollView` and wrapped it in a `UIViewRepresentable`. No glitches, no thousands of re-fetches. The trickiest part was figuring out how to tweak the `CATransform3D`. In brief: vary the `m14` component by ±0.001. – Andrew Duncan Apr 18 '20 at 20:29