ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Issue] SwiftUI: Canvas 컴포넌트가 onAppear로 인한 @State 값 변경을 업데이트 하지 않음
    Apple🍎/SwiftUI 2025. 3. 24. 19:34

    문제 상황

    최근 SwiftUI로 모래 시뮬레이션 앱을 개발하던 중 이상한 문제를 발견했습니다. 간단히 말해, Canvas를 사용해 그리드와 모래를 그리고 @State 변수를 통해 모래의 위치를 관리하는 상황이었습니다. 

    struct ContentView: View {
        let columns = 40
        let rows = 40
        let cellSize: CGFloat = 10
        
        // Grid의 각 cell의 상태 값 : 0 = empty, 1 = sand
        @State private var grid = Array(repeating: Array(repeating: 0, count: 40), count: 40)
        
        var body: some View {
            VStack {
                Text("모래 칠하기")
                    .font(.largeTitle)
                    .padding()
                
                Canvas { context, size in
                    // 그리드 라인 그리기
                    for row in 0...rows {
                        // 가로 선 그리기 코드
                    }
                    
                    for column in 0...columns {
                        // 세로 선 그리기 코드
                    }
                    
                    // 모래 그리기
                    for row in 0..<grid.count {
                        for column in 0..<(grid[row].count) {
                            if grid[row][column] == 1 {
                                let rect = CGRect(
                                    x: CGFloat(column) * cellSize,
                                    y: CGFloat(row) * cellSize,
                                    width: cellSize,
                                    height: cellSize
                                )
                                
                                context.fill(Path(rect), with: .color(.yellow))
                            }
                        }
                    }
                }
                .frame(width: CGFloat(columns) * cellSize, height: CGFloat(rows) * cellSize)
                .background(Color.black)
                .border(Color.white)
            }
            .onAppear {
                initGrid()  // 여기서 grid 초기화
            }
        }
        
        private func initGrid() {
            var newGrid = Array(repeating: Array(repeating: 0, count: columns), count: rows)
            
            // 중앙에 모래 추가
            let centerRow = rows / 2
            let centerCol = columns / 2
            
            for r in (centerRow-2)...(centerRow+2) {
                for c in (centerCol-2)...(centerCol+2) {
                    if r >= 0 && r < rows && c >= 0 && c < columns {
                        newGrid[r][c] = 1
                    }
                }
            }
            
            grid = newGrid  // @State 변수 업데이트
        }
    }
    

     onAppear에서 initGrid() 함수를 호출하여 grid 상태를 업데이트했지만, Canvas에는 모래가 표시되지 않았습니다.

    하지만 버튼을 추가하고 뷰가 등장한 이후에 그 버튼을 통해 grid를 업데이트하면  Canvas상에 모래가 정상적으로 표시되었습니다.

    즉, 초기 렌더링에서만 문제가 발생하는 것이었습니다.

    원인 분석

    여러 실험을 통해 추축해본 원인은 다음과 같습니다. 

    1. Canvas와 @State 변수의 특별한 관계: Canvas 내부에서 grid를 참조하더라도, SwiftUI의 뷰 업데이트 사이클에 제대로 반영되지 않는 것 같았습니다.
    2. 명시적 의존성 부족: 다른 일반 SwiftUI 뷰들과 달리, Canvas는 내부 드로잉 클로저에서 @State 변수를 사용하더라도 이를 의존성으로 제대로 인식하지 못하는 것 같았습니다.
    3. 렌더링 사이클 타이밍 이슈: onAppear에서 상태를 업데이트하면 Canvas는 이미 첫 번째 렌더링을 완료한 후일 수 있고, 따라서 변경사항이 반영되지 않는 것 같았습니다. 

    발견한 해결책

    여러 방법을 시도한 끝에, 다음과 같은 해결책들을 사용해볼 수 있었습니다. 

    1. DispatchQueue.main.async 사용하기

    가장 간단하고 효과적인 방법은 onAppear 내에서 DispatchQueue.main.async를 사용하여 상태 업데이트를 다음 렌더링 사이클로 지연시키는 것이었습니다.

    .onAppear {
        DispatchQueue.main.async {
            initGrid()
        }
    }
    

    이 방법은 초기 렌더링 사이클이 완료된 후 상태를 업데이트하므로, Canvas가 변경사항을 인식하고 다시 그립니다.

    2. 다른 SwiftUI 요소에서 상태 참조하기

    다른 방법은 뷰의 다른 부분에서 grid 상태를 명시적으로 참조하는 것입니다.

    Text("모래 개수: \(grid.count)")  // grid의 count 프로퍼티 읽기

    이렇게 하면 SwiftUI가 grid 변경을 감지하고 전체 뷰를 업데이트하게 됩니다.

    3. Canvas 대신 ZStack 사용하기

    보다 근본적인 해결책은 Canvas 대신 일반 SwiftUI 뷰를 사용하는 것입니다.

    ZStack {
        // 배경
        Rectangle()
            .fill(Color.black)
            .frame(width: CGFloat(columns) * cellSize, height: CGFloat(rows) * cellSize)
            .border(Color.white)
        
        // 그리드 및 모래 그리기 코드
        ForEach(0..<rows, id: \.self) { row in
            ForEach(0..<columns, id: \.self) { column in
                if grid[row][column] == 1 {
                    Rectangle()
                        .fill(Color.yellow)
                        .frame(width: cellSize, height: cellSize)
                        .position(
                            x: CGFloat(column) * cellSize + cellSize/2,
                            y: CGFloat(row) * cellSize + cellSize/2
                        )
                }
            }
        }
    }
    

    이 방법은 SwiftUI의 표준 뷰 시스템을 사용하므로 상태 변화에 더 일관되게 반응합니다.

    뭐가 문제일까? : SwiftUI의 뷰 렌더링 사이클 

    이러한 문제가 발생하는 근본적인 이유를 추측해보자면 SwiftUI의 뷰 렌더링 사이클과 관련이 있는 것 같습니다.

    1. 기본 렌더링 사이클: SwiftUI는 상태 변화가 감지되면 body 프로퍼티를 다시 계산하고 뷰 계층을 업데이트합니다.
    2. onAppear 타이밍: onAppear는 뷰가 화면에 나타난 직후에 호출됩니다. 이 시점에서 첫 번째 렌더링은 이미 완료된 상태입니다.
    3. Canvas의 특수성: Canvas는 SwiftUI의 일반적인 선언적 뷰와 달리, 더 명령형에 가까운 그래픽 API입니다. 내부 드로잉 클로저는 상태 변경 감지 메커니즘과 완전히 통합되지 않은 것으로 보입니다.
    4. 의존성 추적: SwiftUI는 일반적으로 @State 변수가 실제로 "읽히는" 코드를 추적하여 의존성을 결정합니다. Canvas 내부의 참조는 이 메커니즘에서 제대로 인식되지 않는 것 같습니다.

    SwiftUI가 뷰 렌더링 방식을 선언적으로 바꾸는 과정에서 UIKit과 저수준 프레임워크까지 통합해야했기때문에 특정 컴포넌트를 사용할 때 예상과 다르게 동작하는 경우가 발생하는 것 같습니다. 따라서 위와 같이 논리상으로 오류가 없는 경우에도 뷰가 제대로 렌더링되지 않는 상황에는 뷰 렌더링 사이클의 앞 뒤로 추가적으로 디버깅을 찍어서 값의 변경 상태를 먼저 확인하고 그 뒤에 onChange나 id 와 같은 방법을 통해 명시적으로 변경에 대응하여 뷰가 다시 렌더링 될 수 있게 해야할 것 같습니다. 

    댓글

Designed by Tistory.