호댕의 iOS 개발

[TWL] 22.02.07 ~ 22.02.11 (View Drawing Cycle, SplitView, CoreGraphics, CocoaPods, CoreData 등) 본문

Software Engineering/TIL

[TWL] 22.02.07 ~ 22.02.11 (View Drawing Cycle, SplitView, CoreGraphics, CocoaPods, CoreData 등)

호르댕댕댕 2022. 2. 20. 13:08


View Drawing Cycle

필요에 의해 발생하며, 뷰가 처음 그려지거나 레이아웃이 변하게 되면 그리게 된다. 

 

참고로 CumtomView의 경우 다른 뷰 위에 뷰를 얹는 것을 의미하는 것이 아니다. 실질적으로 draw(rect:) 메서드를 override하여 graphic context 위에 그리는 것을 의미한다. 

 

그러면 언제 View의 컨텐츠 변경을 위해 업데이트 트리거가 동작하는 것일까?

 

1. View를 부분적으로 가린 다른 View의 이동 및 제거

2. Hidden으로 되어있던 View의 노출

3. 화면을 스크롤할 때 

4. 명시적으로 setNeedsDisplay 같은 메서드를 호출할 때 

 

그렇다면 View와 Layout을 업데이트해주는 메서드에 대해 좀 더 자세하게 알아보자. 

  • setNeedsDisplay() : 뷰의 요소(background color, alpha 등) 변경 사항을 다음 드로잉 사이클에 업데이트 되야 한다고 시스템에 알림. 비동기로 처리됨.
  • setNeedsLayout() : 뷰 자체의 크기 및 위치 변경 사항(레이아웃)을 다음 드로잉 사이클에 업데이트가 필요하다고 시스템에 알림.
  • displayIfNeeded() : 뷰의 요소를 즉시 업데이트
  • layoutIfNeeded() : 뷰의 레이아웃을 즉시 업데이트

 

draw

draw 메서드 공식문서를 보면 아래와 같이 말하고 있다.

You should never call this method directly yourself

이는 draw 메서드를 시스템에서 알아서 호출하기 때문에 따로 호출을 할 필요가 없다는 뜻이다. 만약 draw 메서드를 직접 호출하고 싶다면 setNeedsDisplay 메서드를 호출하면 된다.

draw 메서드의 경우 레이아웃이 복잡하거나 가로 세로로 계속 변환이 되는 경우(가로 세로로 변환하는 경우도 draw 메서드가 호출된다) 호출이 굉장히 많이 될 수 있다. 따라서 이 메서드의 경우 코드를 최대한 가볍게 작성하는 것이 좋다. 만약 여기서 너무 무거운 작업을 수행하게 된다면 성능상으로 매우 불리하게 된다.

 

Split View

  • iPad에선 탭바 대신 SplitView를 사용하자. 탭바보다 대형 디스플레이에서 빠른 탐색을 제공하며 잘 활용할 수 있다.
  • 각각의 column 유형에 적합한 스타일을 선택하자.
  • 지속적으로 첫 번째, 두 번째 열에서 선택한 내용을 강조 표시로 활성화해놓자.

iOS 14 이후로 column-style layout을 제공하고 있다.

  • DoubleColumn 스타일: 두 개의 자식 ViewController를 가지고 있다. (primary and secondary columns)
  • TripleColumn 스타일: 세 개의 자식 VeiwController를 가지고 있다. (primary, supplementary, secondary)

iOS 14 이전에는 primary와 secondary ViewController만 가지고 있는 하나의 splitView 인터페이스만 지원했다. 이는 unspecified에 정의되어 있으며 iOS 14 이후로는 이에 응답하지 않는다.

splitView의 자식 뷰를 설정할 때에는 setViewController(_:for:) 메서드를 사용한다.

Display Mode

다만

displayMode

는 직접 설정할 수 없는 get-only 프로퍼티이다. 따라서 이를 설정하려면

preferredDisplayMode

를 사용해야 한다.

왼쪽 상단의 버튼의 경우 displayModeButtonItem이다.

 

Cell이 선택되었을 때 View 처리

HIG SplitView를 보면 아래와 같이 나와있다.

Persistently highlight the active selection in the primary and supplementary columns

즉 선택이 되어 있는 columns를 highlight로 표시해야 한다는 것이다. 물론 TableView의 경우 선택을 하게 되면 회색으로 선택된 셀이 자동으로 표시되긴 했으나 조금 더 명확하게 표시할 수 있도록 변경했다.

    override func setSelected(_ selected: Bool, animated: Bool) {
        super.setSelected(selected, animated: animated)
        if selected {
            titleLabel.textColor = .white
            dateLabel.textColor = .white
            previewLabel.textColor = .white
        } else {
            titleLabel.textColor = .label
            dateLabel.textColor = .label
            previewLabel.textColor = .systemGray
        }
    }

    private func configureSelectedBackgroundView() {
        let backgroundView = UIView()
        backgroundView.layer.cornerRadius = 10
        backgroundView.backgroundColor = .systemBlue
        selectedBackgroundView = backgroundView
    }

 

 

이를 위해 UITableViewCell에 정의되어 있는 인스턴스 메서드인 setSelected(_:animated:)를 사용했다. 

 

이는 셀이 선택된 상태를 설정하고 선택적으로 상태 간 전환에 대해 애니메이션을 만들 수 있는 메서드이다.

이 메서드를 통해 선택되었을 때에는 레이블들의 글씨가 흰색으로 바뀌게 하고, 배경은 .systemBlue로 바뀔 수 있도록 구현했다.

또한 ContentView의 배경색을 바꿨을 때에는 AccessoryView 부분이 색이 따로 변하지 않았다. AccessoryView의 배경색을 직접 바꿔도 변하지 않았다. (아직 여기의 색이 바뀌지 않는 정확한 이유는 잘 모르겠다...🥲)

 

따라서 사용한 것이 selectedBackgroundView였다. 이는 이름 그대로 선택된 셀의 배경으로 사용되는 View이다. 선택될 때만 해당 view가 생기기 때문에 미리 색을 정해둬도 선택될 때만 배경이 변하였다.

 

다크모드 대응

프로젝트 핵심 경험에 다크모드가 포함되어 있어 다크모드에 대해서도 고민을 했다. 이때 textColor를 black으로 해놓으면 다크모드일 때 대응이 안되서 글자가 보이지 않는 문제가 있었다.

따라서 이는 textColor를 .label로 줬다. 이렇게 하니 다크모드일 때는 글자 색이 흰색으로 자동으로 바뀌었고, 라이트모드일 때는 기존과 동일한 검정색으로 자동으로 나왔다.

 

showDetailViewController(_:sender:)

이 메서드를 사용하여 뷰컨트롤러를 화면에 표시하는 프로세스에서 분기하여 뷰컨트롤러를 보여줄 수 있도록 하는 메서드이다. 이 방법을 사용하면 뷰컨트롤러가 NavigationController에 있는지, SplitViewController에 있는지 알 필요가 없다. 실제로 멀티태스킹을 통해 화면을 스플릿뷰로 구성할 경우 앱이 화면의 반반을 각각 차지할 경우 SplitView처럼 나오지 않고 메모 ViewController만 보이는 문제가 있었다. 따라서 이 메서드를 사용하여 두 경우에 맞춰 기능을 하도록 구현을 해주었다.

In a regular environment, the UISplitViewController class overrides this method and installs vc as its detail view controller; in a compact environment, the split view controller’s implementation of this method calls show(_:sender:) instead.

 

Core Graphics

Core Graphics (= Quartz 2D): 이차원으로 그릴 수 있도록 하는 엔진. iOS와 MacOS까지 모두 사용이 가능하며 이 둘의 좌표계는 차이가 있다. (iOS는 좌상단이 (0, 0)이며 MacOS는 좌하단이 (0, 0)이다)

Quartz 2D의 경우 painter's model을 사용한다. 페이지의 페인트 위에 페인트를 겹쳐서 추가적인 그리기 작업을 하며 이를 통해 수정을 하기 때문에 그리는 순서가 매우 중요하다.

UIGrapicsBeginImageContextWithOptions

iOS에선 그려진 컨텍스트가 UIView로 반환된다. 이는 UIGraphicsBeginImageContextWithOptions 함수를 호출해서 생성된다.

이 메서드의 경우 비트맵으로 렌더링하기 위한 그리기 환경을 구성하게 된다. 비트맵 형식의 경우 host-byte 순서를 사용하는 ARGB 32 비트의 픽셀 형식이다. 만약 opaque 파라미터가 true면 alpha 채널은 무시되고 비트맵은 완전히 불투명한 것으로 처리된다. (CGImageAlphaInfo.noneSkipFirst | KCGBitmapByteOrder32Host) 그렇지 않으면 각 픽셀은 미리 다중화된 ARGB 형식을 사용하게 된다.

(CGImageAlphaInfo.premultipliedFirst | kCGBitmapByteOrder32Host)

이런 환경은 또한 UIView의 기본 좌표계를 사용한다. origin은 좌측 상단에 위치해 있으며 양의 값일 경우 아래와 오른쪽으로 확장된다.

제공된 크기 비율은 좌표계 및 결과 이미지에도 적용된다. 이 함수에 의해 생성된 컨택스트가 현재 컨텍스트인 동안 UIGraphicsGetImageFromCurrentImageContext() 메서드를 호출하여 컨텍스트의 현재 컨텐츠를 기반으로 이미지 객체를 검색할 수 있다. 수정이 완료된 경우 UIGraphicsEndImageContext() 메서드를 반드시 호출하여 비트맥 그리기 환경을 정리하고 컨텍스트 Stack의 가장 상단에서 graphics context를 제거해야 한다.

스택에서 이러한 유형의 컨텍스트를 제거하기 위해 UIGraphicsPopContext() 함수를 사용해선 안된다.

대부분의 다른 측면에서 이 함수에 의해 생성된 그래픽 컨텍스트의 경우 다른 그래픽 컨텍스트처럼 동작한다. 다른 컨텍스트를 push, pop 하면서 컨텍스트를 수정할 수 있다.

또한 UIGrapicsGetCurrentContext() 메서드를 사용해서 비트맵 컨텍스트를 가져올 수도 있다.

이 함수는 앱의 어떤 스레드든 호출할 수 있다.

활동학습 - 체크 버튼을 만들어보자!

@IBDesignable
class CheckButton: UIButton {
    @IBInspectable private var lineWidth: CGFloat = 0
    @IBInspectable private var strokeColor: UIColor = .systemBlue
    @IBInspectable private var fillColor: UIColor = .systemRed

    override func draw(_ rect: CGRect) {
        // context 영역에 그리게 된다.
        // 현재 graphics context를 가지고 옴.
        guard let context = UIGraphicsGetCurrentContext() else { return }

        let height = bounds.height
        let width = bounds.width

        let circleRect = bounds.insetBy(dx: width * 0.05, dy: height * 0.05)

        context.beginPath()
        context.setLineWidth(lineWidth) // 선 굵기 스토리보드에서 조정 가능
        context.setFillColor(fillColor.cgColor) // 채우기 색
        context.setStrokeColor(strokeColor.cgColor) // 선 색
        context.addEllipse(in: circleRect) // 원으로 그려줘
        context.drawPath(using: .fillStroke) // 그리는 방법 선택

        // 체크 표시를 그려보자
        if isSelected {
            context.beginPath()
            context.setLineJoin(.round) // .bevel하면 끝이 잘리게 됨.
            context.setLineCap(.round)
            context.move(to: CGPoint(x: width * 0.2, y: height * 0.5))
            context.addLine(to: CGPoint(x: width * 0.45, y: height * 0.75))
            context.addLine(to: CGPoint(x: width * 0.8, y: height * 0.3))
            context.drawPath(using: .stroke)
        }
        context.closePath() // 한 번만 해보면 된다.
        // CGAffine 사용해서 회전을 할 수 있다.
    }
}

 

@IBInspectable / @IBDesignable

  • @IBInspectable @IBInspectable는 inspector와 관련이 있다. 정해준 속성이 런타임에 적용되게 된다. 커스텀한 UIView 컴포턴트에서 Inspector 창을 이용해 보다 쉽게 attribute를 적용시킬 수 있도록 한다.
  • @IBDesignable 스토리보드가 draw 메서드의 변경 사항에 따라 즉시 업데이트될 수 있도록 한다. live rendering을 사용하여 스토리보드에 코드의 내용을 반영하게 된다.

 

preferredSplitBehavior

 

Apple Developer Documentation

 

developer.apple.com

  • Tile : 사이드바와 secondary viewController를 나란히 표시함.
  • overlay: 사이드바와 secondary viewController를 계층화하여 secondary viewController를 부분적으로 보이게 한다.
  • displace: 사이드바는 secondary viewController와 겹치는 대신 부분적으로 화면 밖으로 이동하여 보여진다.

 

podFile.lock

podFile.lock을 통해 라이브러리의 버전을 확인할 수 있다. 업데이트가 되면서 기존 앱이 동작하지 않는 문제가 있을 수 있는데 이 때 기존의 버전을 lock해서 다른 사람이 프로젝트를 받아 pod install을 했을 때 유효한 버전을 받을 수 있도록 한다.

사진에서 보이듯 사용하고 있는 라이브러리의 버전과 코코아팟의 버전을 확인할 수 있다.

Podfile을 살펴보자~

 

평소에 Podfile을 만들면 그냥 있어서 아무 생각없이 사용하던 것들이다.

빨간 색으로 표시된 것은 어떤 의미를 가지고 있을까?

 

먼저 use_frameworks!는 무슨 의미일까?

이는 앞의 설명을 보면 어떤 것인지 유추해볼 수 있다.

# Comment the next line if you don't want to use dynamic frameworks 라고 되어 있다.

즉 dynamic frameworks를 사용하길 원하질 않는다면 이를 주석 처리하라고 되어 있다.

일단 라이브러리와 프레임워크를 다시 생각해보자.

프레임워크의 경우 앱을 만드는데 꼭 필요한 것이다. 예를 들어 우리가 응용 프로그램을 만들 때 주로 사용하는 UIKit이나 Foundation이 그러하다.

따라서 이는 준수해야 하는 특정 제한이 존재한다. 이를 준수하지 않는다면 컴파일 에러나 warning이 발생한다.

라이브러리의 경우 앱을 만드는데 도움을 주는 도구이다. 현재 프로젝트에서 사용하고 있는 SwiftLint가 그 예이다. 물론 여기서 특정 조건에 따라 컴파일 에러나 warning이 뜨긴 하나 조건을 직접 수정하고 적용하지 않을 수 있다는 점에서 프레임워크와는 다르다.

 

그럼 dynamic과 static의 차이는 뭘까?

일단 dynamic framework / static framework / dynamic library / static library 모두 존재할 수 있다.

static library

Static Linker를 통해 Static Library의 코드가 어플리케이션 코드 내로 들어가 Heap 메모리에 상주한다.

😃 장점

  1. 여러 프로그램에서 재사용 가능하다.
  2. 런타임 시 실행 속도가 빠르다. (이미 바이너리로 된 객체 코드가 실행 파일에 포함되어 있어 함수에 대한 여러 번의 호출을 빠르게 처리 가능하다)

😱 단점

  1. 컴파일을 하지 않으면 수정이 불가하다.
  2. 여러 프로그램에서 사용할 경우 복사본이 필요해 실행 파일의 크기가 커진다

Dynamic Library

Xcode를 통해 프레임워크를 만들면 기본적으로 Dynamic Framework로 만들어진다.

😃 장점

  1. 실행 파일 외부에 별도의 파일로 따로 존재해서 컴파일 없이 수정 가능하다.
  2. 실행 중인 여러 응용 프로그램이 각각의 고유한 복사본을 가질 필요 없이 동일한 라이브러리를 사용할 수 있다.

😱 단점

  1. 실행 파일 외부에 있어 손상되기 쉽다.

Cocoapods의 경우 1.5 버전부터 Static Framework도 지원하기 시작했다.

그럼 다시 Podfile로 돌아가보자.

Podfile에서 use_frameworks! 는 사실 framework에 중점을 두고 있진 않다. 위에 설명처럼 Dynamic을 사용할 것인지가 더 중요한 점이다.

그럼 왜 Cocoapods에서 frameworks라고 기재한 것일까? 그 이유는 정확하진 않지만 Dynamic이 주로 framework이기 때문으로 추측했다.

그럼 밑에 있는 inherit! 은 대체 뭘까?

이는 코코아팟 공식 문서에 간단하게 나와있다.

Sets the inheritance mode for the current target.

현재 타켓에 대해 상속 모드를 정해주는 것이다.

여기에는 3가지 상속 모드가 존재한다.

  • complete: 부모로부터 모든 행동을 상속받는다.
  • none: 부모로부터 어떤 행동도 상속받지 않는다.
  • search_paths: search path의 부모만 상속받는다.

 

스토리보드를 제거하고 코드로만 구현해보기

참고: https://medium.com/swift-productions/ios-start-a-project-without-storyboard-xcode-12-253d785af5e7

  1. 스토리보드 파일을 제거한다.
  2. 프로젝트의 general 탭에 Deployment Info로 가서 Main Interface를 제거한다.
  3. Info.plist로 가서 Storyboard와 관련된 것들을 제거한다.
  4. SceneDelegate에서 아래 코드를 추가한다. 여기서 window?.makeKeyAndVisible()을 작성해주지 않으면 화면이 나오지 않는다.
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    guard let windowScene = scene as? UIWindowScene else { return }
    window = UIWindow(windowScene: windowScene)
    let mainSplitViewController = MainSplitViewController(style: .doubleColumn)
    mainSplitViewController.preferredDisplayMode = .oneBesideSecondary
    mainSplitViewController.preferredSplitBehavior = .tile
    window?.rootViewController = mainSplitViewController
    window?.makeKeyAndVisible()
}

codegen

Data Model을 생성하고 Attribute의 Inspector를 열어보면 3가지 옵션이 존재한다.

이 옵션들은 각각 어떤 차이가 있을까?

 

일단 Data Model을 누르고 editor > Create NSManagedObject Subclass... 를 누르게 되면 파일이 자동으로 생성된다.

import Foundation
import CoreData

@objc(Memo)
public class Memo: NSManagedObject {

}
import Foundation
import CoreData

extension Memo {

    @nonobjc public class func fetchRequest() -> NSFetchRequest<Memo> {
        return NSFetchRequest<Memo>(entityName: "Memo")
    }

    @NSManaged public var body: String?
    @NSManaged public var lastModified: Double
    @NSManaged public var title: String?

    var convertedDate: String {
        let dateFormatter = DateFormatter.shared
        let currentDate = Date(timeIntervalSince1970: lastModified)
        
        return dateFormatter.string(from: currentDate)
    }
}

바로 요런 파일이 Entity 이름+CoreDataClass , Entity 이름+CoreDataProperties라는 이름으로 생성된다.

그럼 각각은 가장 처음 말했던 codegen의 설정은 어떤 의미를 갖는 것일까?

참고: https://developer.apple.com/documentation/coredata/modeling_data/generating_code

  • Class Definition

두 파일을 자동으로 생성하게 된다. 두 파일에 전혀 편집할 필요없이 그대로 사용할 경우 해당 설정을 선택하게 된다.

여기선 Memo의 인스턴스를 따로 생성하지 않아도 이를 사용할 수 있게 된다. 즉, 보이진 않지만 소스파일을 임의로 생성해준 것이다.

  • Category/Extension

convenience methods나 business logic을 서브 클래스 내부에 추가할 수 있다. 이 옵션을 선택하면 클래스 파일을 완전히 제어할 수 있고, 자동으로 프로퍼티 파일을 생성하여 model editor를 최신의 상태로 유지할 수 있다.

다만 아직 이는 언제 사용해야 하는지 정확히는 파악하지 못했다.

  • Manual/None

관리하는 객체를 지원하는 파일을 따로 생성하지 않는다. subclass의 프로퍼티를 편집할 수 잇다.

 

프로젝트 내 Location

프로젝트 내에 파일을 생성할 때 Location 옵션이 존재한다.

여기서 Absolute Path를 설정하게 되면 본인의 컴퓨터에 있는 절대 경로로 설정되어 다른 사람이 해당 파일을 찾을 수 없는 문제가 발생한다.

따라서 Relative to Group 옵션으로 선택해야 한다.

 

CoreData는 어디에 저장될까?

defaultDirectoryURL()를 통해 알아본 결과 Application Support에 저장되는 것을 알 수 있었다.

Comments