Search code examples
swiftxcodeaccessibilitylagvoiceover

VoiceOver very laggy/slow on screen with many subViews


I am building full accessibility into my iOS Game called Swordy Quest: https://apps.apple.com/us/app/swordy-quest-an-rpg-adventure/id1446641513

As you can see from the screenshots on the above link, there is a Map I have created with 50x50 individual UIViews with a UIButton on each all located on a UIScrollView. With VoiceOver turned off the whole app (including the Map section) works fine - though the map can be a little slow to load at times. When I turn on VoiceOver the whole app responds fine except for the Map Section, which gets very laggy - almost unplayable on my iPhone 7 (like to have an old phone to test worst user experiences).

I have tried removing image detail if VoiceOver is turned on, but that makes no difference at all. This is making me think the lag is due to the 50 x 50 UIViews all of which have an accessibilityLabel added. Does VoiceOver start to lag badly if there are too many accessible labels on a single UIViewController?

Does anyone know a clever way to get around this? I wondered if maybe was a clever way you could turn off AccessibilityLabels except for when a UIView/UIButton is in the visible section of the UIScrollView?


Solution

  • You should not instantiate and render 2500 views at once.
    Modern day devices may handle it reasonably well but it still impacts performance and memory usage and should be avoided.
    Supporting VoiceOver just surfaces this kind of bad practice.

    The right tool to use here is a UICollectionView. Only visible views will be loaded to memory, which limits the performance impact significantly. You can easily implement custom layouts of any kind including a x/y scrollable tile-map like you need it.

    Please see the following minimal implementation to get you started. You can copy the code to the AppDelegate.swift file of a freshly created Xcode project to play around with and adapt it to your needs.

    // 1. create new Xcode project
    // 2. delete all swift files except AppDelegate
    // 3. delete storyboard
    // 4. delete references to storyboard and scene from info.plist
    // 5. copy the following code to AppDelegate
    
    import UIKit
    
    
    // boilerplate
    @main
    class AppDelegate: UIResponder, UIApplicationDelegate {
    
        var window: UIWindow?
    
        func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
            self.window = UIWindow(frame: UIScreen.main.bounds)
            let layout = MapLayout(columnCount: 50, itemSize: 50)!
            self.window?.rootViewController = ViewController(collectionViewLayout: layout)
            self.window?.makeKeyAndVisible()
            return true
        }
    }
    
    class MapLayout: UICollectionViewLayout {
        
        let columnCount: Int
        let itemSize: CGFloat
        var layoutAttributesCache = Dictionary<IndexPath, UICollectionViewLayoutAttributes>()
    
        init?(columnCount: Int, itemSize: CGFloat) {
            guard columnCount > 0 else { return nil }
            self.columnCount = columnCount
            self.itemSize = itemSize
            super.init()
        }
    
        required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
        
        override var collectionViewContentSize: CGSize {
            let itemCount = self.collectionView?.numberOfItems(inSection: 0) ?? 0
            let width = CGFloat(min(itemCount, self.columnCount)) * itemSize
            let height = ceil(CGFloat(itemCount / self.columnCount)) * itemSize
            return CGSize(width: width, height: height)
        }
    
        // the interesting part: here the layout is calculated
        override func prepare() {
            let itemCount = self.collectionView?.numberOfItems(inSection: 0) ?? 0
            for index in 0..<itemCount {
    
                let xIndex = index % self.columnCount
                let yIndex = Int( Double(index / self.columnCount) )
    
                let xPos = CGFloat(xIndex) * self.itemSize
                let yPos = CGFloat(yIndex) * self.itemSize
    
                let cellAttributes = UICollectionViewLayoutAttributes(forCellWith: IndexPath(item: index, section: 0))
                cellAttributes.frame = CGRect(x: xPos, y: yPos, width: self.itemSize, height: self.itemSize)
    
                self.layoutAttributesCache[cellAttributes.indexPath] = cellAttributes
            }
        }
            
        override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
            layoutAttributesCache.values.filter { rect.intersects($0.frame) }
        }
        
        override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
            self.layoutAttributesCache[indexPath]!
        }
        
        override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { false }
    }
    
    // boilerplate
    class ViewController: UICollectionViewController {
    
        var columnCount: Int { (self.collectionViewLayout as! MapLayout).columnCount }
        var rowCount: Int { columnCount }
    
        override func viewDidLoad() {
            super.viewDidLoad()
            self.collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "Cell")
        }
    
        override func numberOfSections(in collectionView: UICollectionView) -> Int { 1 }
    
        override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { rowCount * columnCount }
    
        override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath)
            let fancyColor = UIColor(hue: CGFloat((indexPath.row % columnCount))/CGFloat(columnCount), saturation: 1, brightness: 1 - floor( Double(indexPath.row) / Double( columnCount) ) / Double(rowCount), alpha: 1).cgColor
            cell.layer.borderColor = fancyColor
            cell.layer.borderWidth = 2
            return cell
        }
    }
    
    

    The result should look something like this:

    tilemap collection view demo