8

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?

Charlie S
  • 4,366
  • 6
  • 59
  • 97
  • 1
    Maybe you want to use a collection view instead – aheze Jun 21 '21 at 22:11
  • @aheze I wondered about a collectionview but am fairly sure if there are different size issues on different screens the views may wrap which would mess up the map. Similarly zooming on a collectionview I think would reshape the map? – Charlie S Jun 21 '21 at 22:19
  • 4
    Not really... check out [`UICollectionViewDelegateFlowLayout`](https://developer.apple.com/documentation/uikit/uicollectionviewdelegateflowlayout), which gives you full control over the cell sizes. You can have each cell be 1/50th of the view's width, for example. – aheze Jun 21 '21 at 22:22
  • 1
    @charlie - Btw I have played your game, congrats, I really like it a lot! However I have encountered an unrecoverable bug: after a crash (probably out of memory, see my answer) the game freezes at startup and is subsequently killed by the system. A shame I was just at the shrine and want to continue! – de. Jul 13 '21 at 20:40
  • 1
    A collection view is not a bad idea especially if you're just using squares. I suspect there is something consuming resources in your code somewhere. For that you should look into instruments. try starting here https://help.apple.com/instruments/mac/current/#/dev44b2b437 – SeaSpell Jul 16 '21 at 01:07
  • @de the crashing bug is now fixed – Charlie S Jul 16 '21 at 05:07
  • You should mark the answer as accepted if it solves your problem. Otherwise, the bounty won't go to the person who answered it correctly. – Josh Jul 16 '21 at 20:05

1 Answers1

2

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

de.
  • 7,068
  • 3
  • 40
  • 69