32YB SOPT/애니메이션 스터디

[SOPT] 애니메이션_3주차 (ContentOffset, Skeleton Animation)

신_이나 2023. 6. 13. 02:44

 

 

애니메이션 클론코딩 스터디 

in SOPT iOS

 

 

 

 

 

3주차는 ContentOffset 와 Skeleton Animation 에 대하여 공부하였다.

ContentOffset 이란 UIScrollView의 하위 프로퍼티중 하나다. 직관적으로 말하자면, ContentOffset 은 내가 스크롤 한 만큼의 좌표라고 생각할 수 있다. 3주차에서는 이를 이용하여 스크롤를 한계까지 늘렸을 때, 이미지가 늘어나 뷰의 백그라운드를 감추는 실습을 진행하였다. 그렇게 한 것이 더 에쁘다고 한다! 그렇다면 실습을 시작해보자 !

 

 

기존 화면

왼쪽은 익범 스장님이 보내주신 기존 코드, 오른쪽은 실습을 마친 뒤 완성 코드로 시뮬레이터를 돌려보았을 때 화면이다. 왼쪽처럼 어떠한 효과도 주지 않는다면 끝까지 당겼을 때 사진 크기가 변경되지 않고 당겨진다. 하지만 애니메이션을 추가하여 당겼을 때 확대되었다가 다시 돌아가는 화면을 구현하였다.

 

 

그럼 오늘 사용할 ContentOffset 이란 무엇일까?

 

직역하자면, content 의 시작점이 스크롤뷰의 시작점의 거리다... ?  예쁘게 포장해서 말해보면, ContentOffset 은 내가 스크롤 한 만큼의 좌표 라고 말할 수 있다. 따라서 스크롤 한 만큼의 거리가 얼마나 떨어져있는지를 계산하여 content 이미지가 늘어나게 되는 것이다. 위 시뮬레이터를 구현한 코드에서 두 가지 함수를 추가하면 완성되는 간단한(?) 애니메이션 이었다. 그럼 우선 익범스장님이 보내주신 코드부터 쪼개보자

 

 

 

- TableViewHeader class

 

총 3개의 파일을 주셨는데, 먼저 TableViewHeader class 는 header 에 있는 사진의 layout 을 재설정해주는 class 다. 아래는 TableViewHeader class 의 전체코드

class TableViewHeader: UITableViewHeaderFooterView {
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }
    
    override init(reuseIdentifier: String?) {
        super.init(reuseIdentifier: reuseIdentifier)
        self.setLayout()
    }
    
    internal func bindData(imagePath: String) {
        guard let url = URL(string: imagePath) else {return}
        self.imageView.kf.setImage(with: url)
    }
    
    private func setLayout() {
        self.addSubview(imageView)
        imageView.snp.makeConstraints {
            $0.top.bottom.leading.trailing.equalToSuperview()
        }
    }
    
    private let imageView = UIImageView().then {
        $0.contentMode = .scaleAspectFill
    }
}

쪼개보자!

 

 

required init?(coder: NSCoder) {
        super.init(coder: coder)
}
override init(reuseIdentifier: String?) {
        super.init(reuseIdentifier: reuseIdentifier)
        self.setLayout()
}

 

required init 이란 init() 을 재정의해줘야 할때 required 를 앞에 붙여 사용한다. requried init?(coder: NSCoder) 와 같이 required init 과 (coder: NSCoder) 를 같이 써주는 경우가 많은데 이는 스토리보드나 Xib 파일을 이용해 UI 를 구성하여 이를 그대로 사용하는 것이 가능하게끔 해주는 함수로, 스토리보드를 사용하여 코드를 주셨기 때문에 위와 같이 작성하신 것 같다. 보통 UIView 를 상속받아서 생성자를 사용하려고 하면 requried init?(coder: NSCoder) 를 작성한다.

 

override init 이란 수퍼클래스와 같은 이름으로 초기화할 때 사용한다. 

 

 

internal func bindData(imagePath: String) {
        guard let url = URL(string: imagePath) else {return}
        self.imageView.kf.setImage(with: url)
    }

private, public 과 같이 internal 도 범위를 정하는 것인데, internal 이란 기본 접근 레벨이다. 하나의 모듈 내부에서만 접근 가능하며 내부 접근 수준의 level 이다. 위 함수는 image 를 묶어주는 bind 함수이다.

 

 

private func setLayout() {
        self.addSubview(imageView)
        imageView.snp.makeConstraints {
            $0.top.bottom.leading.trailing.equalToSuperview()
        }
    }
    
    private let imageView = UIImageView().then {
        $0.contentMode = .scaleAspectFill
    }
    
    internal func setResizeView(_ yValue: CGFloat) {
            self.imageView.snp.remakeConstraints {
                $0.top.bottom.leading.trailing.equalToSuperview().inset(yValue)
            }
        }

위는 image 에 대한 layout 을 설정한 것이다. setResizeView 의 layout 을 다시 잡아주는 것이다. ⭐️이번 실습의 메인 코드⭐️

 

 

 

- CustomTVC class

 

두 번째는 CustomTVC 로 애니메이션을 구현하는 class 다. 우선 아래는 CustomTVC 의 전체코드

//
//  CustomTVC.swift
//  Stretch-Header-example
//
//  Created by ikbum on 2023/05/10.
//

import UIKit

class CustomTVC: UITableViewCell {
    static let identifier: String = "CustomTVC"

    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style,
                   reuseIdentifier: reuseIdentifier)
        self.backgroundColor = .white
        self.setLayout()
    }
    
    private func setLayout() {
        self.addSubview(label)
        label.snp.makeConstraints {
            $0.centerY.equalToSuperview()
            $0.leading.trailing.equalToSuperview().inset(50)
            $0.height.equalTo(30)
        }
    }
    
    private func bindText() {
        self.label.text = "안녕하세요!"
        self.label.backgroundColor = .clear
    }
    
    private let label = UILabel().then {
        $0.textColor = .blue
    }
    
}

쪼개보자!

 

 

static let identifier: String = "CustomTVC"

    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style,
                   reuseIdentifier: reuseIdentifier)
        self.backgroundColor = .white
        self.setLayout()
        self.skeletonAnimate()
    }
    
    private func setLayout() {
        self.addSubview(label)
        label.snp.makeConstraints {
            $0.centerY.equalToSuperview()
            $0.leading.trailing.equalToSuperview().inset(50)
            $0.height.equalTo(30)
        }
    }

위 코드는 처음 view 가 나타날 때 보이는 swift 사진과 아래 cell 들에 대한 layout 과 관련된 코드다.

 

 

 private func bindText() {
        self.label.text = "안녕하세요!"
        self.label.backgroundColor = .clear
    }
    
    private let label = UILabel().then {
        $0.textColor = .blue
    }

bindText()와 label 은 는 각 cell 안에 넣고자 하는 text 다.

 

 

 

- ViewController class

 

ViewController 는 위에서 짠 class 들을 모아 view 를 뙇 하고 보여주고자 하는 class 다. 그럼 전체코드!

import UIKit

class ViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.setLayout()
        self.setTableViewConfig()
    }
    
    private func setLayout() {
        self.view.addSubview(tableView)
        tableView.snp.makeConstraints {
            $0.top.leading.trailing.bottom.equalToSuperview()
        }
    }
    
    private func setTableViewConfig() {
        self.tableView.delegate = self
        self.tableView.dataSource = self
        
        let headerView = TableViewHeader()
        headerView.bindData(imagePath: "https://www.hackingwithswift.com/uploads/swift-evolution-7.jpg")
        
        self.tableView.tableHeaderView = headerView
        self.tableView.register(CustomTVC.self,
                                forCellReuseIdentifier: CustomTVC.identifier)
        self.tableView.tableHeaderView?.frame = .init(origin: .zero,
                                                      size: .init(width: UIScreen.main.bounds.width,
                                                                  height: 300))
    }
    
    private let tableView = UITableView(frame: .zero, style: .plain).then {
        $0.contentInsetAdjustmentBehavior = .never
    }
}

extension ViewController: UITableViewDelegate {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        
        if scrollView.contentOffset.y < 0 {
            if let headerView = self.tableView.tableHeaderView as? TableViewHeader {
                headerView.setResizeView(scrollView.contentOffset.y)
            }
        }
    }
}

extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 30
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: CustomTVC.identifier) as? CustomTVC else {return UITableViewCell()}
        return cell
    }
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return 100
    }
}

쪼개보자!

 

 

private func setTableViewConfig() {
        self.tableView.delegate = self
        self.tableView.dataSource = self
        
        let headerView = TableViewHeader()
        headerView.bindData(imagePath: "https://www.hackingwithswift.com/uploads/swift-evolution-7.jpg")
        
        self.tableView.tableHeaderView = headerView
        self.tableView.register(CustomTVC.self,
                                forCellReuseIdentifier: CustomTVC.identifier)
        self.tableView.tableHeaderView?.frame = .init(origin: .zero,
                                                      size: .init(width: UIScreen.main.bounds.width,
                                                                  height: 300))
    }

tableViewCell 구현하는 코드! 지난 세미나에서 한 번 해보았는데 이렇게 다시 보니 반갑다. tableView 에 대해 delegate, dataSource 선언해주었고, register 하여 등록도 해주었다. 

 

 

   
    private let tableView = UITableView(frame: .zero, style: .plain).then {
        $0.contentInsetAdjustmentBehavior = .never
    }
}

tableView 선언하였는데 contentInsetAdjustmentBehavior 를 처음 보았다.

contentInsetAdjustmentBehavior 란 scrollview 의 content 영역에 safe area insets 를 어떤 방식으로 적용할건지 결정하는 프로퍼티다. scrollview 는 overlapping bars 를 고려하여 content inset 을 조정하는데, 이를 조절할 수 있는 옵션이 contentInsetAdjustmentBehavior 이다. 따라서 never 로 선언하게 되면 safe area inset 을 무시하게 된다. 쉽게 말하면, safe area 너머 content 가 시작되지않도록 safe area 안에서부터 시작되도록 하는 것이 contentInsetAdjustmentBehavior 이다.

 

 

extension ViewController: UITableViewDelegate {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        
        if scrollView.contentOffset.y < 0 {
            if let headerView = self.tableView.tableHeaderView as? TableViewHeader {
                headerView.setResizeView(scrollView.contentOffset.y)
            }
        }
    }
}

contentOffset의 y 값이 0보다 작다면 ( = 헤더가 내려왔다면 = 사진을 늘렸다면 ) headerView 의 size 를 다시 설정해준다. 따라서 사진을 직접적으로 늘리지 않아도 실제로는 늘어난 것처럼 y 값을 조정해주는 것이다. ⭐️이번 실습의 메인 코드⭐️

 

아래는 내가 이해못하자 친절하게 설명해준 익범 오빠의 영상 ,,, 체고

 

 

extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 30
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: CustomTVC.identifier) as? CustomTVC else {return UITableViewCell()}
        return cell
    }
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return 100
    }
}

cell 등록해주는 함수 !

 

 

이번 실습에서 이미지를 늘려줄 때 사용하는  메인 코드는 아래 두 부분이다.

//CustomTVC class 내부
internal func setResizeView(_ yValue: CGFloat) {
        self.imageView.snp.remakeConstraints {
            $0.top.bottom.leading.trailing.equalToSuperview().inset(yValue)
        }
    }

//ViewController class 내부
func scrollViewDidScroll(_ scrollView: UIScrollView) {
        if scrollView.contentOffset.y < 0 {
            if let headerView = self.tableView.tableHeaderView as? TableViewHeader {
                headerView.setResizeView(scrollView.contentOffset.y)
            }
        }
    }

 

 

 

Skeleton Animation

Skeleton Animation 이란 어떤 특정 컴포넌트 위치에 해당 내용이 로딩되고 있다면 명시적으로 보여주기 위해 사용된다. 잘 만들어진 오픈소스도 많다고 한다. 

 

위처럼 안녕하세요 글씨가 올라오기 전에 회색박스로 미리 로딩되고 있음이 나타난다. 이를 코드로 작성해보자.

 

private func skeletonAnimate() {
            self.label.backgroundColor = .gray
            UIView.animateKeyframes(withDuration: 2, delay: 0) {
                UIView.addKeyframe(withRelativeStartTime: 0,
                                   relativeDuration: 1/2) {
                    self.label.alpha = 0.4
                }
                UIView.addKeyframe(withRelativeStartTime: 1/2,
                                   relativeDuration: 1) {
                    self.label.alpha = 1
                }
            } completion: { _ in
                self.bindText()
            }
        }

animateKeyframes 와 addKeyframe 을 설정하여 시간에 대한 설정을 해주었다! animateKeyframes 는 애니메이션을 중첩하고자 할 때 사용한다. 예를 들어, 1번 애니메이션 후에 2번을 하고 싶다.. 이러면 이제 animateKeyframes 를 불러주면 되는 것! 반복하고자 하는 요소를 넣어주고 completion 으로 '여기있습니다~' 해주면 된다. 그렇다면 반복하고자 하는 애니메이션이 여러개면 completion 도 여러개를 써야겠지? 이때 addKeyframe 이 도움이 된다. 간단히 말하자면 시작점과 끝점을 지정하는 효과를 적용할 수 있다. UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 1/2) 는 애니메이션 함수가 호출되고 0초 후에 해당 애니메이션이 0.5초 재생된다는 것이다. 이를 이용하면 completion 핸들러에 핸들러가 물리고 ,,, 라는 일이 발생하지 않는다고 한다.

따라서 위 코드는 시작부터 0.5초까지 label 의 alpha 가 0.4로, 그 뒤에 1초 동안 alpha 가 1로 바뀐다는 의미다. 애니메이션이 끝났다면 text 가 등장한다. 이를 이용하면 천천히 애니메이션이 뜨는 구현이 가능하다!

 

정말 정말 신기하네 ,,, 이걸로 과제해야겠다. 매력적이야