 Apple Lover Developer & Artist

영속적인 디자인에 현대의 공감을 채워넣는 공방입니다

 Apple/Stanford iOS Programming (SwiftUI)

Lecture 6 Review Part 3: Protocols Shapes

singularis7 2021. 8. 26. 21:03
반응형

Shape Protocol

Shape 프로토콜은 View를 상속받고 있는 프로토콜이다. 즉, Shape도 View의 일종이라는 것이다. SwiftUI에서 이미 Shape를 사용해본 적이 있다.

  • RoundedRectangle
  • Circle
  • Capsule
  • etc...

위에서 언급된 도형을 기본적으로 사용한다면 foreground color를 사용하여 View 내부 색깔을 채우게 될 것이다. 하지만 stroke 혹은 fill modifier 를 사용하여 윤곽선만 그리거나 내부에 색깔을 채우도록 설정할 수도 있다. 

생각해보면 뭔가 이상한게 있다. stroke를 쓰던 fill을 쓰던 메서드의 인자로 Color가 넘어가거나 Gradient가 넘어가는 등 타입을 가리고 있지 않기 때문이다.

위와 같이 동작할 수 있었던 이유는 메서드가 generic 타입으로 정의되었기 때문이다. 다만 제네릭 타입의 인자에 모든 타입이 다들어올 수 있는 것이 아닌 where절에 명시된 ShapeStyle 프로토콜을 만족하는 타입만 인자로 넘길 수 있다. 

여기서 ShapeStyle 프로토콜은 Shape class를 가져와서 ShapeStyle을 적용하여 View로 변환하는 방법을 알고 있는 프로토콜이다. 인자로 보낸 Color, Gradient 같은 타입이 모두 ShapeStyle 프로토콜을 만족하는 타입이었던 것이다.

나만의 Shape 만드는 방법?

Shape 프로토콜은 View를 상속받기 때문에 반드시 var body를 구현하도록 요구할 것이다. 하지만 Shape가 대신 var body를 구현해줄 것이기 때문에 작성할 필요가 없다. 대신에 path(in:) 이라는 메서드를 구현해줄 것을 요구하고 있다.

path(in: )함수는 Path 구조체를 리턴하고 있으며 선, 커브등의 2차원 shape에 대한 outline을 그릴 수 있도록 지원해주는 친구이다.

Demo!

강의 초반에 본 Memorize App에 완성본을 보면서 우리가 구현해야할 목표가 무엇인지 상기시켜볼 필요가 있다.

뒤집힌 상태의 카드를 클릭하면 카드의 앞면이 보이면서 파이 차트가 카운트다운을 시작한다. 여기서 파이 차트의 카운트다운 부분을 구현하는 것이 이번시간의 목표이다. 구현 목표를 자세히 보면 다음과 같이 생겼다.

처음에는 이모지 뒷쪽에 파이차트를 배치시키는 것을 목표로 할 것이다. emoji 뒤에 Circle을 배치시켜보자!

화면에 보이는 것처럼 Circle이 RoundedRectangle을 가득 채우고 있으며 심지어 윤곽선과 겹치고 있다. 색상도 테마처럼 높은 saturation을 갖고 있어서 다른 요소와 파이차트가 구분이 되지 않는다.

이 문제점을 해결하기 위해 Circle에 padding을 좀 주고 opacity값을 줘서 배경색과 어느정도 섞이게 하면 다음과 같이 동작한다.

생긴 것은 어느정도 비슷해졌다. 불행이도 SwiftUI에는 Pie 모양이 존재하지 않는다. 따라서 Pie 모양을 다시 설계해야 한다. 어떻게 구현해야 할까? Circle Shape를 대체할 Pie Shape 를 새롭게 정의해보자.

Pie 모양을 구현하기 위해 Shape 프로토콜을 따르는 Pie 구조체를 선언했다. Xcode에서 Shape 프로토콜이 요구하는 변수나 함수를 구현해 달라는 오류메시지가 뜨는 것을 볼 수 있다. Fix 버튼을 클릭하면 다음과 같이 path 메서드를 구현할 수 있도록 새로운 코드가 작성된다.

path 메서드의 입력으로는 CGRect 타입의 rect를 받고 있으며 Path 타입을 리턴하고 있다. 우선 CGRect에 대해서 간단히 이해해보자!

Apple에 따르면 CGRect가 rectangle의 location과 dimensions를 정의한 구조체라고 정의하고 있다. 따라서 기하학적 의미를 갖는 property로 다음과 같은 요소를 갖고 있다.

  • var origin: CGPoint | 좌표계에서 rectangle의 origin을 구체화 하는 좌표값
  • var size: CGSize | rectangle의 height와 width의 크기를 정의한 프로퍼티

위와 같은 내용을 보면 당연히 생각해보아야 할 내용이 있다. 지난 학기에 그래픽스 수업을 들으면서 좌표값만 보면 계속 의심하게 된다.

Rectangle이 정의되는 좌표계는 어떻게 생겨먹은 놈인데?

놀랍게도 Apple은 공식문서의 Overview 부분에서 좌표계에 관하여 소개하고 있다. Core Graphics 시스템은 가벼운 2D 렌더링을 위해 사용되는데 기본적으로 좌하단에 origin을 두고 우상단으로 확장하는 방식의 일반적으로 우리가 중고등 과정에서 배운 좌표계 시스템을 그대로 사용한다.

출처: 위키피디아 (https://en.wikipedia.org/wiki/Cartesian_coordinate_system)

하지만 iOS의 경우 일반적인 데카르트 좌표계와는 다르게 좌표계가 뒤집힌다고 한다. 다시 말해 좌표계의 origin이 좌하단에 있던게 좌상단으로 바뀌고 우하단으로 확장해 나가는 방식으로 바뀐다. 

출처: 위키피디아 (https://en.wikipedia.org/wiki/Cartesian_coordinate_system)

이렇게 보니깐 차이가 한눈에 보인다.

좌표계와 좌표값을 이해한다면 사실 파이 차트 따위 구현하는 것은 일도 아니다. 우리가 Pie 차트를 구현하기 위해서 필요한 요소가 무엇이 있을까? 가장 먼저 생각해 볼 수 있는 것은 원(Circle)을 떠올릴 수 있다. 원의 정의를 한번 생각해보자

A circle is a shape consisting of all points in a plane that are at a given distance from a given point.

주어진 점으로 부터 같은 거리에 있는 점들로 이루어진 모양을 Circle 이라고 정의하고 있다. 주로 주어진 점을 원의 중심 "center" 이라고 부르며 주어진 거리를 원의 지름 혹은 반지름 "radius" 라고 부른다.

우선 카드 안에 보여질 원을 정의해야한다. 원은 카드 내부의 중심에 위치하고 있으니 카드의 중심에 대한 좌표를 찾아야 한다. 카드는 GeometryReader로 정의되어있으니 Flexible할 것이고 자신에게 주어진 공간을 최대한 점유하려고 할 것이다.

카드가 정의된 사각형 모양의 공간을 담고 있는 변수가 있다. 바로 CGRect 타입을 갖는 rect 변수가 이에 해당된다. rect 변수에는 다음과 같이 사각형에 대한 중점을 알려주는 메서드를 제공하고 있다.

  • var midX: CGFloat | 사각형의 중점에 해당하는 x좌표를 리턴합니다.
  • var midY: CGFloat | 사각형의 중점에 해당하는 y좌표를 리턴합니다.

이제 원의 중심을 정의할 수 있게 되었다! Core Graphics 를 사용하여 캡슐화된 좌표값을 사용하려면 다음과 같이 구현하면 된다.

let center = CGPoint(x: rect.midX, y: rect.midY)

원의 중심을 알았다면 반지름을 생각해볼 차례이다. 원의 반지름은 카드의 어느 부분에 맞추어야 할까? 카드의 aspect ratio가 변하면 어떻게 반지름 사이즈를 대응하여 계산할 수 있을까에 대한 질문을 만족하는 코드가 등장해야 한다.

간단한 아이디어로 사각형의 가로 혹은 세로폭 중에서 작은 녀석을 반지름으로 취하면 되지 않을까라고 생각해보는게 직관적일 수 있다. 따라서 다음과 같은 코드가 등장하게 되었다.

let radius = min(rect.width, rect.height) / 2

이제 원이 그려질 시작 지점을 정의해야 한다. 원 위의 좌표는 일반적으로 삼각함수를 활용하여 표현된다. 

let start = CGPoint(
    x: center.x + radius * CGFloat(cos(startAngle.radians)),
    y: center.y + radius * CGFloat(sin(startAngle.radians))
)

여기까지 각종 좌표를 정의함으로써 그림을 그릴 준비를 마쳤다. 이제부터 Path 구조체를 활용하여 실제 그림을 그려보는 일만 남았다.

struct Pie: Shape {
    // 애니메이션을 구현하기 위해서 var로 정의하였다. 즉, mutable 하다.
    var startAngle: Angle
    var endAngle: Angle
    var clockwise: Bool = false
    
    func path(in rect: CGRect) -> Path {
        
        let center = CGPoint(x: rect.midX, y: rect.midY)
        let radius = min(rect.width, rect.height) / 2
        let start = CGPoint(
            x: center.x + radius * CGFloat(cos(startAngle.radians)),
            y: center.y + radius * CGFloat(sin(startAngle.radians))
        )
        
        var p = Path()
        p.move(to: center)
        p.addLine(to: start)
        p.addArc(
            center: center,
            radius: radius,
            startAngle: startAngle,
            endAngle: endAngle,
            clockwise: clockwise
        )
        p.addLine(to: center)
        
        return p
    }
}

p 를 일종의 펜처럼 생각하면 위 코드를 이해하기 쉬워진다.

  • p 를 종이에 닿지 않도록 들어올린 다음에 center 좌표로 위치를 옮긴다.
  • 현재 p가 위치한 곳에서부터 start까지 선을 그린다.
  • center와 radius 까지의 Arc를 그릴 것인데 startAngle부터 endAngle까지 clockwise 값을 선택하여 그린다
  • 펜이 endAngle 위치에 있을텐데 다시 center까지 선을 그린다.

위와 같은 방식으로 사용자 정의 Shape를 정의할 수 있으며 결과가 잘 그려지는지 한번 확인해보도록 하자!

각도 시스템은 라디안이 아닌 일반적인 degree 시스템을 사용했다. 결과적으로 파이 차트가 그려지기는 하는데 조금 이상하게 동작한다. 과연 각도가 어느 방향으로 계산되고 있던 것일까? 위 코드에서 잘못구현하고 있는 내용을 찾아보자!

  1. 시작 지점이 0인데 화면 상단 방향이 아닌 우측 방향으로 그려지는 것으로 보아 x축을 기준으로 각도를 계산하는 것으로 보인다.
  2. 각도가 커짐에 따라 초반에 화면의 하단 방향으로 커지고 있으며 좌표계가 뒤집혀 있음을 알 수 있다.

우리는 화면 상단에서부터 반시계방향으로 시간을 카운트하는 기능을 구현하고 싶다. 방법이 없을까? 우선 시작 지점을 화면의 상단부로 가져오기 위해 90도를 빼보도록 하자!

위치는 얼추 맞아들어가는 것 같지만 붉은색으로 표시된 부분을 지금 위치의 여집합 부분으로 그려지도록 바꿔보고 싶어진다.

clockwise의 방향을 바꿔줌으로써 해결되었다! 원리를 이해했으니 어떤 모양도 만들어낼 수 있을 것이다!

반응형