Skip to the content.

iOS 16 - SwiftUI Layout의 Cache에 대해

iOS 16 SwiftUI에서 Layout이라는 커스텀 레이아웃을 짤 수 있게 하는 기능이 추가되었습니다. 본래는 GeometryReader로 커스텀 레이아웃을 구현해야 했으나, 써보신 분은 아시겠지만 frame 변동 때문에 loop가 걸리는 문제가 있어 굉장히 버벅거리거나 무한 loop에 걸리는… 문제가 발생할 수 있어서 비효율적이었습니다. 이를 해결하기 위해 iOS 16 부터 Layout이 등장했습니다.

Layout은 UICollectionViewLayout을 통한 커스텀 레이아웃을 짜는 것과 굉장히 유사하며 더 간단합니다. 또한 Layout은 Cache 기능을 지원해서 불필요한 레이아웃 연산을 줄여서 성능 향상을 이끌 수 있습니다. 하지만 WWDC22의 Compose custom layouts with SwiftUI 영상에서는 Cache 공부는 Documentation을 보고 알아서 공부하라고… 하네요.

For many simple layouts, you won't need this, and I'm just going to ignore the cache for now. However, if profiling your app with Instruments shows that you need to improve the efficiency of your layout code, you can look into adding one. Check out the documentation for more information about that.

애플에서 제공하는 샘플 프로젝트에서는 Cache가 아래처럼 구현되어 있긴 한데요.

import SwiftUI

struct MyEqualWidthVStack: Layout {
    /* ... */
    
    /// A type that stores cached data.
    /// - Tag: CacheData
    struct CacheData {
        let maxSize: CGSize
        let spacing: [CGFloat]
        let totalSpacing: CGFloat
    }

    /// Creates a cache for a given set of subviews.
    ///
    /// When the subviews change, SwiftUI calls the `updateCache(_:subviews:)`
    /// method. The `MyEqualWidthVStack` layout relies on the default
    /// implementation of that method, which just calls `makeCache(subviews:)`
    /// to recreate the cache.
    /// - Tag: makeCache
    func makeCache(subviews: Subviews) -> CacheData {
        let maxSize = maxSize(subviews: subviews)
        let spacing = spacing(subviews: subviews)
        let totalSpacing = spacing.reduce(0) { $0 + $1 }

        return CacheData(
            maxSize: maxSize,
            spacing: spacing,
            totalSpacing: totalSpacing)
    }

    /* ... */
}

다만 이 코드는 View의 사이즈 변화, 다시 말해 ProposedViewSize 변화에 대응하는 예시가 아닙니다. 아래 영상같은 레이아웃을 구성해서 View의 사이즈 변화에 따른 연산을 캐싱해서 중복된 연산을 줄이고 싶은건데 그거를 보여주는 코드가 아닌거죠.

위 영상은 아래 코드로 레이아웃을 구성했습니다. 코드를 다 이해하실 필요는 없고, 레이아웃 계산을 위해 연산이 많다고만 이해하시면 됩니다. 참고로 아래 코드는 아직 캐싱 기능은 구현되지 않은 상태이며, 캐싱 기능을 구현하는 방법을 소개하려고 합니다.

import SwiftUI
import ByeDarkSkyCore

struct WeatherLayout: Layout {
    enum ContentMode {
        case fill, fit
    }
    
    private let itemSize: CGSize
    private let horizontalContentMode: ContentMode
    
    init(itemSize: CGSize = .init(width: 150, height: 150), horizontalContentMode: ContentMode = .fit) {
        self.itemSize = itemSize
        self.horizontalContentMode = horizontalContentMode
    }
    
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let largestHorizontalSpacing: CGFloat = subviews.enumerated().reduce(.zero) { partialResult, enumeration in
            let currentIndex: Int = enumeration.offset
            let nextIndex: Int = currentIndex + 1
            
            guard nextIndex < subviews.count else {
                return partialResult
            }
            
            let currentSubview: LayoutSubview = enumeration.element
            let nextSubview: LayoutSubview = subviews[nextIndex]
            
            return max(partialResult, currentSubview.spacing.distance(to: nextSubview.spacing, along: .horizontal))
        }
        
        let width: CGFloat = proposal.width ?? .zero
        let spacedWidth: CGFloat = width + largestHorizontalSpacing
        let columnCount: Int = Int(trunc(spacedWidth / (itemSize.width + largestHorizontalSpacing)))
        guard columnCount > 0 else { return .zero }
        let rowCount: Int = (subviews.count / columnCount) + ((subviews.count % columnCount == .zero) ? .zero : 1)
        
        let totalVerticalSpacing: CGFloat = (0..<rowCount).reduce(.zero) { partialResult1, row in
            let startIndex: Int = row * columnCount
            let endIndex: Int = startIndex + columnCount - 1
            
            guard endIndex < subviews.count else {
                return partialResult1
            }
            
            let largestVerticalSpacing: CGFloat = (startIndex...endIndex).reduce(.zero) { partialResult2, index in
                let currentIndex: Int = index
                let nextIndex: Int = index + columnCount
                
                guard nextIndex < subviews.count else {
                    return partialResult2
                }
                
                let currentSubview: LayoutSubview = subviews[currentIndex]
                let nextSubview: LayoutSubview = subviews[nextIndex]
                
                return max(partialResult2, currentSubview.spacing.distance(to: nextSubview.spacing, along: .vertical))
            }
            
            return partialResult1 + largestVerticalSpacing
        }
        
        let finalSize: CGSize
        let finalHeight: CGFloat = (CGFloat(rowCount) * itemSize.height) + CGFloat(totalVerticalSpacing)
        
        switch horizontalContentMode {
        case .fit:
            let finalWidth: CGFloat = (CGFloat(columnCount) * itemSize.width) + (CGFloat(columnCount - 1) * largestHorizontalSpacing)
            finalSize = .init(width: finalWidth, height: finalHeight)
        case .fill:
            finalSize = .init(width: width, height: finalHeight)
        }
        
        return finalSize
    }
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let largestHorizontalSpacing: CGFloat = subviews.enumerated().reduce(.zero) { partialResult, enumeration in
            let currentIndex: Int = enumeration.offset
            let nextIndex: Int = currentIndex + 1
            
            guard nextIndex < subviews.count else {
                return partialResult
            }
            
            let currentSubview: LayoutSubview = enumeration.element
            let nextSubview: LayoutSubview = subviews[nextIndex]
            
            return max(partialResult, currentSubview.spacing.distance(to: nextSubview.spacing, along: .horizontal))
        }
        
        let width: CGFloat = bounds.width
        let spacedWidth: CGFloat = width + largestHorizontalSpacing
        let columnCount: Int = Int(trunc(spacedWidth / (itemSize.width + largestHorizontalSpacing)))
        guard columnCount > 0 else { return }
        let rowCount: Int = (subviews.count / columnCount) + ((subviews.count % columnCount == .zero) ? .zero : 1)
        var yPosition: CGFloat = bounds.origin.y
        var locations: [CGPoint] = []
        
        (0..<rowCount).forEach { row in
            let firstColumn: Int = row * columnCount
            let lastColumn: Int = {
                // for last row
                let tmp: Int = firstColumn + columnCount - 1
                
                if tmp < subviews.count {
                    return tmp
                } else {
                    return subviews.count - 1
                }
            }()
            
            let totalWidth: CGFloat = (firstColumn...lastColumn).reduce(.zero) { partialResult, currentIndex in
                let nextIndex: Int = currentIndex + 1
                
                guard (currentIndex < lastColumn) && (nextIndex < subviews.count) else {
                    return partialResult + itemSize.width
                }
                
                let currentSubview: LayoutSubview = subviews[currentIndex]
                let nextSubview: LayoutSubview = subviews[nextIndex]
                let spacing: CGFloat = currentSubview.spacing.distance(to: nextSubview.spacing, along: .horizontal)
                
                return partialResult + itemSize.width + spacing
            }
            
            log.debug(totalWidth)
            
            let horizontalSpacing: CGFloat = (bounds.width - totalWidth) / CGFloat(2)
            let verticalSpacing: CGFloat = {
                let startIndex: Int = row * columnCount
                let endIndex: Int = startIndex + columnCount - 1
                
                guard endIndex < subviews.count else {
                    return .zero
                }
                
                let largestVerticalSpacing: CGFloat = (startIndex...endIndex).reduce(.zero) { partialResult2, index in
                    let currentIndex: Int = index
                    let nextIndex: Int = index + columnCount
                    
                    guard nextIndex < subviews.count else {
                        return partialResult2
                    }
                    
                    let currentSubview: LayoutSubview = subviews[currentIndex]
                    let nextSubview: LayoutSubview = subviews[nextIndex]
                    
                    return max(partialResult2, currentSubview.spacing.distance(to: nextSubview.spacing, along: .vertical))
                }
                
                return largestVerticalSpacing
            }()
            var xPosition: CGFloat = bounds.origin.x + horizontalSpacing
            
            (firstColumn...lastColumn).forEach { currentIndex in
                guard currentIndex < subviews.count else {
                    return
                }
                
                let currentSubview: LayoutSubview = subviews[currentIndex]
                currentSubview.place(at: .init(x: xPosition, y: yPosition), anchor: .topLeading, proposal: .init(itemSize))
                locations.append(.init(x: xPosition, y: yPosition))
                
                let nextIndex: Int = currentIndex + 1
                
                if (currentIndex < lastColumn) && (nextIndex < subviews.count) {
                    let nextSubview: LayoutSubview = subviews[nextIndex]
                    let spacing: CGFloat = currentSubview.spacing.distance(to: nextSubview.spacing, along: .horizontal)
                    xPosition += itemSize.width + spacing
                }
            }
            
            yPosition += itemSize.height + verticalSpacing
        }
    }
}

이제 캐싱 기능을 구현해 볼게요. CacheStore라는 캐싱 저장소를 만들고 Layout.Cache의 타입을 CacheStore로 지정할게요.

struct WeatherLayout: Layout {
    // NEW
    struct CacheStore {
        struct Geometry: Hashable {
            let bounds: CGRect?
            let proposal: ProposedViewSize
            
            func hash(into hasher: inout Hasher) {
                hasher.combine(bounds?.origin.x)
                hasher.combine(bounds?.origin.y)
                hasher.combine(bounds?.size.width)
                hasher.combine(bounds?.size.height)
                hasher.combine(proposal.width)
                hasher.combine(proposal.height)
            }
        }
        
        var sizes: [Geometry: CGSize] = [:]
        var locations: [Geometry: [CGPoint]] = [:]
    }
    
    typealias Cache = CacheStore
    
    // () -> CacheData
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheStore) -> CGSize { /* ... */ }
    
    // () -> CacheData
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheStore) { /* ... */ }
    
    // NEW
    func makeCache(subviews: Subviews) -> CacheStore {
        let cacheStore: CacheStore = .init()
        return cacheStore
    }
    
    // NEW
    func updateCache(_ cache: inout CacheStore, subviews: Subviews) {
    
    }
}

이제 캐시을 저장하고 불러오는 로직을 짜볼게요.

struct WeatherLayout: Layout {
    /* ... */
    
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheStore) -> CGSize {
        let geometry: WeatherLayoutCacheStore.Geometry = .init(bounds: nil, proposal: proposal)
        
        if let size: CGSize = cacheStore.sizes[geometry] {
            // 캐싱됨 - 불러 옴
            return size
        } else {
            /* 캐싱이 안 됨 - 새로 만들고 저장 */
            
            // 최종 사이즈
            let finalSize: CGSize = /* ... */
            
            // 캐시 저장
            cache.sizes[geometry] = finalSize
            
            return finalSize
        }
    }
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout UUID) {
        let geometry: CacheStore.Geometry = .init(bounds: bounds, proposal: proposal)
        
        if let locations: [CGPoint] = cache.locations[geometry] {
            // 캐싱됨 - 불러 옴
            subviews.enumerated().forEach { enumeration in
                let index: Int = enumeration.offset
                let subview: LayoutSubview = enumeration.element
                let location: CGPoint = locations[index]
                subview.place(at: location, anchor: .topLeading, proposal: .init(itemSize))
            }
        } else {
            /* 캐싱이 안 됨 - Subviews의 좌표를 새로 만들고 저장 */
             
            // Subviews의 좌표
            var locations: [CGPoint] = /* ... */
            
            // 캐시 저장
            cache.locations[geometry] = locations
        }
    }
}

이렇게 하면 View의 사이즈를 대응하는 캐싱이 구현됩니다! 최종 코드는 여기에서 확인해 주세요.