ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • SwiftUI - Canvas
    SwiftUI 2026. 9. 12. 16:11

    안녕하세요. 그린입니다 🍏
    이번 포스팅에서는 SwiftUI의 Canvas 컴포넌트에 대해 애플 공식 문서를 기준으로 정리해보겠습니다 🙋🏻

    Intro

    프레임워크: SwiftUI

    지원 버전: iOS 15 / macOS 12 / watchOS 8 이상

    Canvas?

    일반 SwiftUI 뷰(Circle, Rectangle, Path)를 여러 개 겹쳐 그리면, SwiftUI는 그 뷰들을 실제 뷰 트리 노드로 유지해요(retained mode).

    상태가 바뀌면 diffing해서 필요한 부분만 다시 그리죠.

    Canvas는 이 방식을 버려요.

    대신 클로저 하나를 받아서, 매 프레임 그 클로저를 통째로 다시 실행해요.

    클로저 안에서 GraphicsContext라는 그리기 도구에게 순서대로 명령을 내리는 방식이라 즉시 모드(immediate mode)라고 불러요.

    Canvas 뷰는 매 프레임 renderer 클로저 (inout GraphicsContext, CGSize) -> Void를 실행해 GraphicsContext를 통해 픽셀을 직접 그립니다.

    개별 도형은 뷰 트리에 남지 않아요.

     

    기본적인 사용법 — stroke

    애플 공식 문서의 첫 예제가 아래와 같습니다.

    Canvas { context, size in
        context.stroke(
            Path(ellipseIn: CGRect(origin: .zero, size: size)),
            with: .color(.green),
            lineWidth: 4)
    }
    .frame(width: 300, height: 200)
    .border(Color.blue)
    • context: GraphicsContext — 여기다 대고 "그려라" 명령을 내림
    • size: 캔버스가 차지한 실제 CGSize — 이 크기에 맞춰 좌표를 계산
    • Path(ellipseIn:): 캔버스 크기에 딱 맞는 타원 경로
    • .stroke(_:with:lineWidth:): 경로의 윤곽선만 그림 (채우기 아님)

    stroke 예제 렌더링 결과

     

    GraphicsContext의 핵심 규칙 — "순서가 상태다"

    GraphicsContext는 그냥 그리기 함수 모음이 아니라, 자기 자신이 상태(state)를 갖는 객체예요. opacity, blendMode, clip, transform 같은 값을 바꾸면 그 이후에 실행되는 그리기 명령에만 영향을 줘요. 이미 그려진 건 안 바뀝니다.

     

    애플 공식 문서가 정확히 이걸로 예제를 들어요.

     

    let halfSize = size.applying(CGAffineTransform(scaleX: 0.5, y: 0.5))
    context.clip(to: Path(CGRect(origin: .zero, size: halfSize)))   // 1. 먼저 클립
    context.fill(
        Path(ellipseIn: CGRect(origin: .zero, size: size)),
        with: .color(.green))                                        // 2. 그 다음 채우기

    절반 크기의 사각형으로 먼저 클립을 걸어두면, 그 뒤에 그리는 타원은 클립 영역 밖으로 나간 부분이 안 보여요.

    반대로 fill을 먼저 하고 clip을 나중에 걸면, 클립은 "이후 연산에만" 적용되는 규칙 때문에 이미 그려진 타원엔 아무 영향이 없습니다.

    clip 순서에 따른 렌더링 차이

     

    context는 값 타입 — 복사해서 독립적으로 쓸 수 있다

    GraphicsContext는 struct예요.

    그래서 복사하면 상태가 통째로 복제돼요.

    원본과 복사본은 서로 다른 state(clip, opacity 등)를 가질 수 있지만, 같은 레이어에 그림을 그려요. 즉, "복사본에만 클립을 걸고 그리기"가 가능해요.

    var maskedContext = context          // 상태를 통째로 복사
    let halfSize = size.applying(CGAffineTransform(scaleX: 0.5, y: 0.5))
    maskedContext.clip(to: Path(CGRect(origin: .zero, size: halfSize)))
    maskedContext.fill(
        Path(ellipseIn: CGRect(origin: .zero, size: size)),
        with: .color(.green))            // 이 fill만 클립의 영향을 받음
    
    // 원본 context는 클립이 안 걸린 상태 그대로
    context.fill(
        Path(CGRect(origin: origin, size: halfSize)),
        with: .color(.blue))              // 클립 영향 없이 파란 사각형 그림
    이 패턴은 "특정 도형에만 마스크/필터를 걸고 싶은데, 나머지 그리기엔 영향을 주고 싶지 않을 때" 자주 씁니다.

     

    좌표계 변환 — transform

    GraphicsContext는 내부에 CGAffineTransform을 갖고 있고, scaleBy/rotate/translateBy/concatenate 메서드는 전부 이 transform을 갱신하는 편의 함수예요.

    context.translateBy(x: size.width / 2, y: size.height / 2)  // 원점을 중앙으로
    context.rotate(by: .degrees(45))                              // 45도 회전
    context.scaleBy(x: 1.5, y: 1.5)                                // 1.5배 확대

    공식 문서 표현 그대로, rotate(by:)를 호출하는 건 사실 아래 코드를 대신 해주는 슈가 문법이에요.

     

    transform = transform.rotated(by: angle.radians)

    이 변환들도 순서가 중요해요.

    translate 후 rotate와 rotate 후 translate는 결과가 다릅니다.

    앞서 설명한 "이후 연산에만 적용" 규칙이 여기서도 그대로 적용돼요.

     

    블렌드 모드와 필터

    opacity, blendMode도 같은 원리로 동작해요.

    context.blendMode = .destinationOut   // 이후 그리는 도형으로 배경을 "지운다"
    context.opacity = 0.5                  // 이후 그리는 것들이 반투명해짐

    addFilter(_:options:)로는 이미지 처리 필터도 이후 그리기에 적용할 수 있어요.

    예를 들어 블러 효과는 다음처럼 씁니다.

     

    context.addFilter(.blur(radius: 8))

    GraphicsContext.Filter에는 이 외에도 alphaThreshold, 그리고 ShadowOptions를 활용하는 그림자 필터 등 다양한 케이스가 존재합니다.

     

    SwiftUI 뷰를 통째로 그리기 — symbols

    Canvas가 효율적인 이유 중 하나는, 진짜 SwiftUI 뷰를 "드로잉 재료"로 등록해서 원하는 위치에 여러 번 찍어낼 수 있다는 거예요.

    Canvas { context, size in
        for i in 0..<20 {
            if let symbol = context.resolveSymbol(id: i) {
                let point = CGPoint(x: Double(i) * 15, y: 50)
                context.draw(symbol, at: point, anchor: .center)
            }
        }
    } symbols: {
        ForEach(0..<20) { i in
            Circle()
                .fill(.blue)
                .frame(width: 10, height: 10)
                .tag(i)   // resolveSymbol(id:)에서 이 id로 찾음
        }
    }

    symbols 클로저에 등록된 뷰들은 .tag(_:)로 붙인 id를 통해 resolveSymbol(id:)로 꺼내 쓸 수 있어요.

    이렇게 꺼낸 ResolvedSymbol은 해상도 독립적이라, 같은 심볼을 수십·수백 번 다른 위치에 찍어도 성능이 안정적이에요.

    파티클, 포인트 클라우드, 커스텀 차트 마커 같은 걸 그릴 때 정확히 이 패턴을 씁니다.

     

    텍스트와 이미지 — resolve

    Text나 Image도 바로는 못 그리고, 먼저 환경(폰트, 색상 스킴 등)에 맞춰 "고정"시켜야 해요.

    Canvas { context, size in
        let resolvedText = context.resolve(Text("Hello").font(.title))
        context.draw(resolvedText, at: CGPoint(x: size.width/2, y: 20), anchor: .top)
    }

    resolve(_:)가 Text/Image를 각각 ResolvedText/ResolvedImage로 바꿔주는데, 이 과정에서 context.environment(디스플레이 해상도, 컬러 스킴 등)를 참조해요.

     

    withCGContext

    정말 세밀한 제어가 필요하면 withCGContext(content:)로 진짜 Core Graphics CGContext를 빌려서 그릴 수도 있어요. GraphicsContext가 지원 안 하는 연산이 필요할 때 쓰는 탈출구예요.

     

    언제 Canvas를 써야 하나 — 공식 문서의 트레이드오프

    애플 문서가 명시적으로 경고하는 부분이에요.

     

    Canvas doesn't offer interactivity or accessibility for individual elements, including for views that you pass in as symbols.

     

    즉, symbols로 진짜 Button을 넣어도 탭 이벤트가 안 먹고, VoiceOver도 개별 요소를 인식 못 해요.

    대신 다음 기준으로 판단하면 됩니다.

    • 동적 데이터가 많고, 도형 개수가 유동적인 복잡한 드로잉일수록 유리 (뷰 트리 오버헤드가 없으니까)
    • 텍스트 위주이거나 사용자와 상호작용해야 하는 UI는 Canvas가 아니라 일반 SwiftUI 뷰로

    Conclusion

    Canvas는 SwiftUI 뷰 트리를 포기하는 대신, 매 프레임 GraphicsContext에 명령을 순서대로 내려서 즉시 픽셀을 그리는 고성능 드로잉 도구예요.

    상태(clip/opacity/transform/filter)는 항상 "이후 연산에만" 적용된다는 규칙 하나만 기억하면, 나머지 API는 다 이 규칙의 응용이라고 볼 수 있습니다 🙌

     

    References

     

    Canvas | Apple Developer Documentation

    A view type that supports immediate mode drawing.

    developer.apple.com

     

    GraphicsContext | Apple Developer Documentation

    An immediate mode drawing destination, and its current state.

    developer.apple.com

     

    'SwiftUI' 카테고리의 다른 글

    SwiftUI - TipKit  (1) 2026.10.03
    What's new in SwiftUI (feat. WWDC 2026)  (1) 2026.06.29
    SwiftUI View hierarchy Debugging  (1) 2025.12.13
    SwiftUI Preview - Thunk  (1) 2025.11.29
    SwiftUI's diffing  (5) 2025.09.27
Designed by Tistory.