-
[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상에 모래가 정상적으로 표시되었습니다.
즉, 초기 렌더링에서만 문제가 발생하는 것이었습니다.
원인 분석
여러 실험을 통해 추축해본 원인은 다음과 같습니다.
- Canvas와 @State 변수의 특별한 관계: Canvas 내부에서 grid를 참조하더라도, SwiftUI의 뷰 업데이트 사이클에 제대로 반영되지 않는 것 같았습니다.
- 명시적 의존성 부족: 다른 일반 SwiftUI 뷰들과 달리, Canvas는 내부 드로잉 클로저에서 @State 변수를 사용하더라도 이를 의존성으로 제대로 인식하지 못하는 것 같았습니다.
- 렌더링 사이클 타이밍 이슈: 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의 뷰 렌더링 사이클과 관련이 있는 것 같습니다.
- 기본 렌더링 사이클: SwiftUI는 상태 변화가 감지되면 body 프로퍼티를 다시 계산하고 뷰 계층을 업데이트합니다.
- onAppear 타이밍: onAppear는 뷰가 화면에 나타난 직후에 호출됩니다. 이 시점에서 첫 번째 렌더링은 이미 완료된 상태입니다.
- Canvas의 특수성: Canvas는 SwiftUI의 일반적인 선언적 뷰와 달리, 더 명령형에 가까운 그래픽 API입니다. 내부 드로잉 클로저는 상태 변경 감지 메커니즘과 완전히 통합되지 않은 것으로 보입니다.
- 의존성 추적: SwiftUI는 일반적으로 @State 변수가 실제로 "읽히는" 코드를 추적하여 의존성을 결정합니다. Canvas 내부의 참조는 이 메커니즘에서 제대로 인식되지 않는 것 같습니다.
SwiftUI가 뷰 렌더링 방식을 선언적으로 바꾸는 과정에서 UIKit과 저수준 프레임워크까지 통합해야했기때문에 특정 컴포넌트를 사용할 때 예상과 다르게 동작하는 경우가 발생하는 것 같습니다. 따라서 위와 같이 논리상으로 오류가 없는 경우에도 뷰가 제대로 렌더링되지 않는 상황에는 뷰 렌더링 사이클의 앞 뒤로 추가적으로 디버깅을 찍어서 값의 변경 상태를 먼저 확인하고 그 뒤에 onChange나 id 와 같은 방법을 통해 명시적으로 변경에 대응하여 뷰가 다시 렌더링 될 수 있게 해야할 것 같습니다.
'Apple🍎 > SwiftUI' 카테고리의 다른 글
SwiftUI 멀티 플렛폼 Navigation 아키텍쳐 설계 (Coordinator 야 저리 가라) (0) 2025.04.08 List와 ScrollView+LazyVStack 비교하기 (0) 2025.03.07 SwiftUI 데이터 모델에 actor를 사용하면 안되는 이유 (0) 2025.02.19 클린 아키텍처 쉽게 이해하기 with SwiftUI 🔍 (0) 2024.10.29 SwiftUI가 선언형이라는게 무슨말일까? (0) 2024.06.11