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?
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: