ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [SwiftUI] 4. Drawing Paths and Shapes
    iOS Dev/SwiftUI 2023. 4. 30. 17:04
    반응형

    https://developer.apple.com/tutorials/swiftui/drawing-paths-and-shapes

     

    Drawing Paths and Shapes | Apple Developer Documentation

    Users receive a badge whenever they visit a landmark in their list. Of course, for a user to receive a badge, you’ll need to create one. This tutorial takes you through the process of creating a badge by combining paths and shapes, which you then overlay

    developer.apple.com

    이제 path와 shapes를 사용해서 다양한 뷰를 구성해보자.

    Section 1. Create Drawing Data for a Badge View

    Views/Badges 안에 HexagonParameters.swift 파일을 생성하자. 이번 챕터동안 생성되는 파일은 전부 Badges 그룹 안에 들어간다.

    다음과 같이 HexagonParameters.swift 파일을 완성한다.

    import CoreGraphics
    
    struct HexagonParameters {
        struct Segment {
            let line: CGPoint
            let curve: CGPoint
            let control: CGPoint
        }
    
        static let adjustment: CGFloat = 0.085
    
        static let segments = [
            Segment(
                line:    CGPoint(x: 0.60, y: 0.05),
                curve:   CGPoint(x: 0.40, y: 0.05),
                control: CGPoint(x: 0.50, y: 0.00)
            ),
            Segment(
                line:    CGPoint(x: 0.05, y: 0.20 + adjustment),
                curve:   CGPoint(x: 0.00, y: 0.30 + adjustment),
                control: CGPoint(x: 0.00, y: 0.25 + adjustment)
            ),
            Segment(
                line:    CGPoint(x: 0.00, y: 0.70 - adjustment),
                curve:   CGPoint(x: 0.05, y: 0.80 - adjustment),
                control: CGPoint(x: 0.00, y: 0.75 - adjustment)
            ),
            Segment(
                line:    CGPoint(x: 0.40, y: 0.95),
                curve:   CGPoint(x: 0.60, y: 0.95),
                control: CGPoint(x: 0.50, y: 1.00)
            ),
            Segment(
                line:    CGPoint(x: 0.95, y: 0.80 - adjustment),
                curve:   CGPoint(x: 1.00, y: 0.70 - adjustment),
                control: CGPoint(x: 1.00, y: 0.75 - adjustment)
            ),
            Segment(
                line:    CGPoint(x: 1.00, y: 0.30 + adjustment),
                curve:   CGPoint(x: 0.95, y: 0.20 + adjustment),
                control: CGPoint(x: 1.00, y: 0.25 + adjustment)
            )
        ]
    }

    이렇게 코드로 쓰여있으면 이 도형이 어떤 도형인지 알기 어렵다. 이 코드가 의미하는 바를 그림으로 살짝 그려봤다.

    먼저 하나의 segment는 line, curve, control로 이루어져 있다. 선은 line -> control -> curve (-> line -> control -> curve) 순서대로 지나간다. line까지는 직선이 그려지고, line부터 curve까지는 커브가 그려지는데, 곡선은 control을 지나가는 방식으로 구현된다. 이렇게 6개의 segment를 두어서 육각형을 표현했다.

    중간에 adjustment는 위아래로 벌어지는 정도를 의미한다. (맨 아래와 맨 위 세그먼트는 제외)

    Section 2. Draw the Badge Background

    다음은 BadgeBackground.swift 파일을 생성한다.

    import SwiftUI
    
    struct BadgeBackground: View {
        var body: some View {
            Path { path in
                var width: CGFloat = 100.0
                let height = width
                path.move(
                    to: CGPoint(
                        x: width * 0.95,
                        y: height * 0.20
                    )
                )
                
                HexagonParameters.segments.forEach { segment in
                    path.addLine(
                        to: CGPoint(
                            x: width * segment.line.x,
                            y: height * segment.line.y
                        )
                    )
                }
            }
            .fill(.black)
        }
    }
    
    struct BadgeBackground_Previews: PreviewProvider {
        static var previews: some View {
            BadgeBackground()
        }
    }

    이렇게 생성해두고 preview를 보면,

    저런 모양이 생긴다. 이상하다 생각할 수 있는데, 이전 그림에서 마지막 세그먼트의 커브 = 시작점이라고 했다. 그 시작점부터 각 세그먼트의 line 부분만 선으로 이으면 저런 모양이 된다.

    이제 addQuadCurve 함수를 사용해서 curve와 control요소들을 사용하고, GeometryReader를 통해 뷰 사이즈를 동적으로 결정할 수 있도록 한다.

    import SwiftUI
    
    struct BadgeBackground: View {
        var body: some View {
            GeometryReader { geometry in
                Path { path in
                    var width: CGFloat = min(geometry.size.width, geometry.size.height)
                    let height = width
                    let xScale: CGFloat = 0.832
                    let xOffset = (width * (1.0 - xScale)) / 2.0
                    width *= xScale
                    path.move(
                        to: CGPoint(
                            x: width * 0.95 + xOffset,
                            y: height * (0.20 + HexagonParameters.adjustment)
                        )
                    )
                    
                    HexagonParameters.segments.forEach { segment in
                        path.addLine(
                            to: CGPoint(
                                x: width * segment.line.x + xOffset,
                                y: height * segment.line.y
                            )
                        )
                        path.addQuadCurve(
                            to: CGPoint(
                                x: width * segment.curve.x + xOffset,
                                y: height * segment.curve.y
                            ),
                            control: CGPoint(
                                x: width * segment.control.x + xOffset,
                                y: height * segment.control.y
                            )
                        )
                    }
                }
                .fill(.linearGradient(
                    Gradient(colors: [Self.gradientStart, Self.gradientEnd]),
                    startPoint: UnitPoint(x: 0.5, y: 0),
                    endPoint: UnitPoint(x: 0.5, y: 0.6)
                ))
            }
            .aspectRatio(1, contentMode: .fit)
        }
        static let gradientStart = Color(red: 239.0 / 255, green: 120.0 / 255, blue: 221.0 / 255)
        static let gradientEnd = Color(red: 239.0 / 255, green: 172.0 / 255, blue: 120.0 / 255)
    }
    
    struct BadgeBackground_Previews: PreviewProvider {
        static var previews: some View {
            BadgeBackground()
        }
    }

     

    Geometry Reader는 무엇일까?

    Geometry Reader를 사용하면 하위 뷰에서 해당 Geometry Reader 영역 정보를 알 수 있다. 현재 뷰 크기 등을 알려고할 때 유용하게 쓰인다.

    /// A container view that defines its content as a function of its own size and
    /// coordinate space.
    ///
    /// This view returns a flexible preferred size to its parent layout.
    @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
    @frozen public struct GeometryReader<Content> : View where Content : View {
    
        public var content: (GeometryProxy) -> Content
    
        @inlinable public init(@ViewBuilder content: @escaping (GeometryProxy) -> Content)
    
        /// The type of view representing the body of this view.
        ///
        /// When you create a custom view, Swift infers this type from your
        /// implementation of the required ``View/body-swift.property`` property.
        public typealias Body = Never
    }

    최상단에 GeometryReader는 자신의 사이즈와 coordinate space를 함수형으로 정의하는 컨테이너 뷰라고 써있다.

    정의 부분을 보자. 처음 init될 때 @ViewBuilder로 content를 받고, 이는 @escaping 클로져로, GeometryProxy를 인자로 받아서 Content를 리턴하는 클로져이다. 즉, GeometryProxy를 입력받아서 Content를 리턴하면 된다.

    GeometryProxy는 뭘까?

    /// A proxy for access to the size and coordinate space (for anchor resolution)
    /// of the container view.
    @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
    public struct GeometryProxy {
    
        /// The size of the container view.
        public var size: CGSize { get }
    
        /// Resolves the value of `anchor` to the container view.
        public subscript<T>(anchor: Anchor<T>) -> T { get }
    
        /// The safe area inset of the container view.
        public var safeAreaInsets: EdgeInsets { get }
    
        /// Returns the container view's bounds rectangle, converted to a defined
        /// coordinate space.
        public func frame(in coordinateSpace: CoordinateSpace) -> CGRect
    }

    먼저, proxy는 대리인을 뜻한다. GeometryProxy는 단순하게 GeometryReader에서 사이즈와 coordinate space 등을 접근할 수 있게 해주는 대리인이다. 중간에 subscript는 [ ] (대괄호)를 의미한다. 여기 안에 Anchor를 집어넣으면 값을 알 수 있다.

    CoordinateSpace를 활용하여 global, local, named(이름) 영역에 대한 frame을 알 수 있다. 

    Section 3. draw the Badge Symbol

    AppIcon.appiconset.zip
    0.29MB

    이 파일의 압축을 풀어서 나온 폴더 자체를 Assets 안에 기존에 있던 AppIcon을 지우고 넣어준다.

    다음, BadgeSymbol.swift 파일을 만든다.

    import SwiftUI
    
    struct BadgeSymbol: View {
        var body: some View {
            GeometryReader { geometry in
                Path { path in
                    let width = min(geometry.size.width, geometry.size.height)
                    let height = width * 0.75
                    let spacing = width * 0.030
                    let middle = width * 0.5
                    let topWidth = width * 0.226
                    let topHeight = height * 0.488
                    
                    path.addLines([
                        CGPoint(x: middle, y: spacing),
                        CGPoint(x: middle - topWidth, y: topHeight - spacing),
                        CGPoint(x: middle, y: topHeight / 2 + spacing),
                        CGPoint(x: middle + topWidth, y: topHeight - spacing),
                        CGPoint(x: middle, y: spacing)
                    ])
                }
            }
        }
    }
    
    struct BadgeSymbol_Previews: PreviewProvider {
        static var previews: some View {
            BadgeSymbol()
        }
    }

    width : 전체 너비
    heigth : 전체 높이
    spacing : 가장 위쪽 (y가 0에 가까운 점) 점이 0에서부터 떨어진 거리 & 가장 아래쪽 점이 topHeight로부터 떨어진 거리
    middle : x 중앙
    topWidth : 가장 오른쪽 점 x좌표 (y가 가장 큰 점)
    topHeight : 가장 아래 점 y좌표 (y가 가장 큰 점)

    이런 모양이 나온다. 아래쪽 모양도 마저 그리면,

    import SwiftUI
    
    struct BadgeSymbol: View {
        static let symbolColor = Color(red: 79.0 / 255, green: 79.0 / 255, blue: 191.0 / 255)
        var body: some View {
            GeometryReader { geometry in
                Path { path in
                    let width = min(geometry.size.width, geometry.size.height)
                    let height = width * 0.75
                    let spacing = width * 0.030
                    let middle = width * 0.5
                    let topWidth = width * 0.226
                    let topHeight = height * 0.488
                    
                    path.addLines([
                        CGPoint(x: middle, y: spacing),
                        CGPoint(x: middle - topWidth, y: topHeight - spacing),
                        CGPoint(x: middle, y: topHeight / 2 + spacing),
                        CGPoint(x: middle + topWidth, y: topHeight - spacing),
                        CGPoint(x: middle, y: spacing)
                    ])
                    
                    path.move(to: CGPoint(x: middle, y: topHeight / 2 + spacing * 3))
                    path.addLines([
                        CGPoint(x: middle - topWidth, y: topHeight + spacing),
                        CGPoint(x: spacing, y: height - spacing),
                        CGPoint(x: width - spacing, y: height - spacing),
                        CGPoint(x: middle + topWidth, y: topHeight + spacing),
                        CGPoint(x: middle, y: topHeight / 2 + spacing * 3)
                    ])
                }
                .fill(Self.symbolColor)
            }
        }
    }
    
    struct BadgeSymbol_Previews: PreviewProvider {
        static var previews: some View {
            BadgeSymbol()
        }
    }

    이렇게 된다.

    RotatedBadgeSymbol.swift 파일을 새로 만들어서 다음 코드를 채운다.

    import SwiftUI
    
    struct RotatedBadgeSymbol: View {
        let angle: Angle
        
        var body: some View {
            BadgeSymbol()
                .padding(-60)
                .rotationEffect(angle, anchor: .bottom)
        }
    }
    
    struct RotatedBadgeSymbol_Previews: PreviewProvider {
        static var previews: some View {
            RotatedBadgeSymbol(angle: Angle(degrees: 5))
        }
    }

    주어진 각도만큼 살짝 틀어진 것을 볼 수 있다.

     

    Section 4. Combine the Badge Foreground and Background

    Badge.swift 파일을 하나 생성하자.

    import SwiftUI
    
    struct Badge: View {
        var badgeSymbols: some View {
            ForEach(0..<8) { index in
                RotatedBadgeSymbol(angle: .degrees(Double(index) / Double(8)) * 360.0)
            }
            .opacity(0.5)
        }
        var body: some View {
            ZStack {
                BadgeBackground()
                
                GeometryReader { geometry in
                    badgeSymbols
                        .scaleEffect(1.0 / 4.0, anchor: .top)
                        .position(x: geometry.size.width / 2.0, y : (3.0 / 4.0) * geometry.size.height)
                }
            }
            .scaledToFit()
        }
    }
    
    struct Badge_Previews: PreviewProvider {
        static var previews: some View {
            Badge()
        }
    }

     

    여기서 ZStack이 나온다. ZStack은 하위 뷰들을 쌓아올리는 뷰를 말한다.

    /// A view that overlays its subviews, aligning them in both axes.
    ///
    /// The `ZStack` assigns each successive subview a higher z-axis value than
    /// the one before it, meaning later subviews appear "on top" of earlier ones.
    
    (중략)
    
    @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
    @frozen public struct ZStack<Content> : View where Content : View {
    
        /// Creates an instance with the given alignment.
        ///
        /// - Parameters:
        ///   - alignment: The guide for aligning the subviews in this stack on both
        ///     the x- and y-axes.
        ///   - content: A view builder that creates the content of this stack.
        @inlinable public init(alignment: Alignment = .center, @ViewBuilder content: () -> Content)
    
        /// The type of view representing the body of this view.
        ///
        /// When you create a custom view, Swift infers this type from your
        /// implementation of the required ``View/body-swift.property`` property.
        public typealias Body = Never
    }

    순서대로 뷰에 쌓인다고 생각하면 되고, @ViewBuilder content에 들어오는 뷰에 대해 이전에 들어온 뷰가 나중에 들어온 뷰의 아래에 깔린다.

     

    Check Your Understanding

    답은 2 1 3

    반응형

    댓글

Designed by Tistory.