SeSAC

8.24 ~ 8.25 TIL(SeSAC iOS 3기)

예스코치 2023. 8. 28. 00:57

Generic


  • 타입에 유연하게 대응하기 위한 요소.
  • 제네릭으로 구현한 타입과 기능은 재사용하기 쉽고 코드의 중복도 줄일 수 있기 때문에 깔끔하고 추상적인 표현이 가능.
  • T: Type Parameters플레이스 홀더와 같은 역할을 함.
  • 타입의 종류는 알려주지 않지만 특정한 타입이라는 것은 알려줌.
  • T가 아닌 다른 문자열을 작성해도 되지만, 일반적으로는 UpperCased 또는 T, U 등을 사용함.
🔥 만약 함수를 오버로딩하는데 제네릭을 사용한 경우, 제네릭으로 구현한 함수보다 타입이 명확한 함수가 우선순위가 더 높다.
🔥 프로토콜에서 제네릭을 사용하려면 associated type 을 사용해야 한다.
protocol GenericExample {
    associatedtype T
    
    var variable: T { get }
}

class A: GenericExample {
    var variable: Int { return 100 }
}

struct B: GenericExample {
    var variable: String { return "BBB" }
}
protocol GenericExample {
    associatedtype T: Numeric
    // Numeric: 곱셈을 지원하는 프로토콜
    
    var variable: T { get }
}

class A: GenericExample {
    var variable: Int { return 100 }
}

struct B: GenericExample {
    var variable: Double { return 5.5 }
}

// 다음은 틀린코드 -> String은 Numeric을 충족하지 않으므로 타입으로 들어올 수 없다.
struct C: GenericExample {
    var variable: String { return "CCC" }
}

 

제네릭을 활용한 UI Design 메서드 구현

import UIKit

extension UIViewController {
    // Type Constraints: 클래스 제약, 프로토콜 제약
    func configureBorder<T: UIView>(view: T) {
        view.layer.cornerRadius = 10.0
        view.layer.borderColor = UIColor.black.cgColor
        view.layer.borderWidth = 3.0
        view.layer.masksToBounds = true
    }
}

 

제네릭을 활용한 화면 전환 메서드

extension UIViewController {
    enum TransitionStyle {
        case present    // 네비게이션 없이 present
        case presentNavigation  // 네비게이션 임베드 된 present
        case presentFullNavigation  // 네비게이션 임베드 된 fullscreen present
        case push
    }

    func transition<T: UIViewController>(
      viewController: T.Type,
      storyboard: String,
      style: TransitionStyle
    ) {
        let sb = UIStoryboard(name: storyboard, bundle: nil)
        guard let vc = sb.instantiateViewController(
            withIdentifier: String(describing: viewController)
        ) as? T else { return }

        switch style {
        case .present:
            present(vc, animated: true)
        case .presentNavigation:
            let nav = UINavigationController(rootViewController: vc)
            present(nav, animated: true)
        case .presentFullNavigation:
            let nav = UINavigationController(rootViewController: vc)
            nav.modalPresentationStyle = .fullScreen
            present(nav, animated: true)
        case .push:
            navigationController?.pushViewController(vc, animated: true)
        }
    }
}

/* 사용 예
transition(
  viewController: GenericViewController.self,
  storyboard: "Main",
  style: .presentNavigation
)
*/

 

required init


  • 초기화 구문이 프로토콜에 명시되어 있음을 알려주는 키워드.
  • 스토리보드를 안쓰면 항상 추가해줘야 함.
// 프로토콜을 통해 init 강제
protocol ExampleProtocol {
    init(name: String)
}

class Mobile: ExampleProtocol {

    // required: 프로토콜에서 생성된 경우 사용하는 키워드
    // Required Initializer
    required init(name: String) {
        fatalError()
    }
}

 

CustomView에서 CornerRadius 적용하기(feat. layoutSubViews)


  • UI 컴포넌트의 width를 2로 나눈 값을 cornerRadius로 주면, 원이 된다.
import UIKit

class CustomImageView: UIImageView {
    override init(frame: CGRect) {
        super.init(frame: frame)
        configureView()
    }
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    func configureView() {
        backgroundColor = .systemMint
        layer.borderColor = UIColor.black.cgColor
        layer.borderWidth = 2
        layer.cornerRadius = frame.width / 2
        clipsToBounds = true
    }
}

위에서 구현한 CustomImageView 타입을 실제로 뷰컨트롤러에서 사용해보면,

class ViewController: UIViewController {

    private lazy var customImageView: UIImageView = {
        let imageView = CustomImageView(frame: .zero)
        return imageView
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        configureUI()
    }

    func configureUI() {
        view.addSubview(customImageView)

        customImageView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate(
            [
                customImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
                customImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
                customImageView.widthAnchor.constraint(equalToConstant: 150),
                customImageView.heightAnchor.constraint(equalToConstant: 150)
            ]
        )
    }
}

하지만 막상 실행하면 cornerRadius만 적용이 안된 것을 볼 수 있다.

cornerRadius(frame.width / 2)만 적용이 되지 않았다.

 

사용하는 곳에서 이 이미지뷰를 초기화할 때, frame: .zero 로 했기 때문에 frame.width = 0이라서 반영되지 않았던 것!

 

⭐️⭐️ 해결방법: layoutSubviews()에서 frame 값이 필요한 부분을 작업하면 된다.

layoutSubviews
- UIView의 메서드로, 서브뷰들의 레이아웃을 업데이트한다.
- 불리는 시점
1. 뷰컨트롤러가 메모리에 로드된다(viewDidLoad)
2. viewWillAppear가 호출
3. 뷰컨트롤러의 뷰들이 레이아웃 제약들을 업데이트하고 intrinsicContentSize를 계산한다(updateViewConstraints)
✅ 4. viewWillLayoutSubviews가 호출되면, 뷰들은 layoutSubviews를 호출
5. viewDidLayoutSubviews가 호출되고, 화면에 뷰들을 그린다(drawRect)
6. viewDidAppear 호출
import UIKit

class CustomImageView: UIImageView {

    override init(frame: CGRect) {
        super.init(frame: frame)
        configureView()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func configureView() {
        backgroundColor = .systemMint
        layer.borderColor = UIColor.black.cgColor
        layer.borderWidth = 2
        clipsToBounds = true
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        layer.cornerRadius = frame.width / 2
    }
}

cornerRadius도 깔끔하게 적용되었다.

 

UIPageViewController(feat. Intro, Walkthroungh, Onboarding)


  • 여러개의 뷰 컨트롤러를 페이징이 가능한 하나의 뷰 컨트롤러로 관리해주는 컨테이너 뷰 컨트롤러.
  • UIPageViewControllerDataSourceUIPageViewControllerDelegate 를 통해 페이지뷰컨트롤러의 기능을 구현해야함.

 

전체 코드


1. PageViewController에 담을 UIViewController 구현

import UIKit

class FirstVC: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .brown
    }
}

class SecondVC: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .systemMint
    }
}

class ThirdVC: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .magenta
    }
}

2. PageViewController 구현

import UIKit
class OnboardingViewController: UIPageViewController {

    // 1.
    var list: [UIViewController] = []

    // 화면전환 방식, 방향 설정
    override init(
        transitionStyle style: UIPageViewController.TransitionStyle, 
        navigationOrientation: UIPageViewController.NavigationOrientation,
        options: [UIPageViewController.OptionsKey : Any]? = nil
    ) {
        super.init(transitionStyle: .scroll, navigationOrientation: .horizontal)
    }

    required init?(coder: NSCoder) {
        fatalError()
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        
        list = [FirstVC(), SecondVC(), ThirdVC()]
        
        view.backgroundColor = .systemPink
        delegate = self
        dataSource = self
        
        // 초기 ViewController 등록
        guard let first = list.first else { return }
        setViewControllers([first], direction: .forward, animated: true)
    }
}

3. PageViewControllerDatasource 구현

  • viewControllerBefore : 왼쪽으로 스크롤시, 이전 화면이 보이도록 구현
  • viewControllerAfter : 오른쪽으로 스크롤시, 다음 화면이 보이도록 구현
extension OnboardingViewController: UIPageViewControllerDataSource {
  
    func pageViewController(
        _ pageViewController: UIPageViewController,
        viewControllerBefore viewController: UIViewController
    ) -> UIViewController? {
        guard let currentIndex = list.firstIndex(of: viewController) else { return nil }

        let previousIndex = currentIndex - 1

        return previousIndex < 0 ? nil : list[previousIndex]
    }

    func pageViewController(
        _ pageViewController: UIPageViewController,
        viewControllerAfter viewController: UIViewController
    ) -> UIViewController? {
        guard let currentIndex = list.firstIndex(of: viewController) else { return nil }

        let nextIndex = currentIndex + 1

        return nextIndex >= list.count ? nil : list[nextIndex]
    }
}

4. PageViewControllerDelegate 구현(PageControl 추가)

  • presentationCount : UIPageViewController의 UIPageControl에 표시할 갯수
  • presentationIndex : 현재 페이지임을 표시할 UIPageControl의 인덱스
extension OnboardingViewController: UIPageViewControllerDelegate {
    func presentationCount(for pageViewController: UIPageViewController) -> Int {
        return list.count
    }

    func presentationIndex(for pageViewController: UIPageViewController) -> Int {
        guard let first = viewControllers?.first,
              let index = list.firstIndex(of: first)
        else { return 0 }

        return index
    }
}
UIPageViewController에 내장된 PageControl은 색상 등의 커스터마이징이 힘들다.
따라서 PageControl 속성값에 변화를 주고 싶으면 a) 직접 UIPageControl을 추가해서 변경해야한다.
이경우 a-1) PageControl의 위치 제약, a-2) PageControl의 아이템 갯수와 화면 스와이프시 PageControl CurrentIndex 반영을 직접 구현해줘야 한다.

다른 방법도 있는데, b) UIPageControl의 appearance()를 이용하면 된다.(proxy)
다만 이 방법은 프로젝트의 모든 UIPageControl에 전역적으로 적용되니, 좋은 방법은 아닐 수 있음.

 

페이지 뷰 컨트롤러 무한 스크롤 구현


계산한 이전(다음) 인덱스 값에서 뷰컨트롤러 갯수만큼 더하고, 이를 다시 뷰컨트롤러 갯수로 나눈 나머지값을 반환하면 된다.

extension OnboardingViewController: UIPageViewControllerDataSource, UIPageViewControllerDelegate {

    func pageViewController(
        _ pageViewController: UIPageViewController,
        viewControllerBefore viewController: UIViewController
    ) -> UIViewController? {
        guard let firstIndex = viewControllerList.firstIndex(of: viewController) else { return nil }
        let prevIndex = (firstIndex - 1 + viewControllerList.count) % viewControllerList.count

        return prevIndex < 0 ? viewController : viewControllerList[prevIndex]
    }

    func pageViewController(
        _ pageViewController: UIPageViewController,
        viewControllerAfter viewController: UIViewController
    ) -> UIViewController? {
        guard let firstIndex = viewControllerList.firstIndex(of: viewController) else { return nil }
        let afterIndex = (firstIndex + 1) % viewControllerList.count

        return afterIndex >= viewControllerList.count ? nil : viewControllerList[afterIndex]
    }
}