Starbucks Caramel Frappuccino
본문 바로가기
  • 그래 그렇게 조금씩
UIKit/AutoLayout

20 - SNS View (테이블뷰/셀, JSON parsing, 제스쳐, 오토레이아웃)

by Toughie 2023. 4. 16.

인스타, 페북과 같은 SNS 뷰를 구현해 보는 시간을 가졌다.

오토레이아웃 파트도 중요하지만 크게는 테이블뷰를 활용하는 방식에 대해 더 초점을 맞춰 정리해보고자 한다.

 

[데이터]

먼저 데이터의 경우 JSON 형식으로 외부에서 받아오는 경우가 많다.

(open API를 사용한다든가.. 대부분의 API에서 JSON 형식으로 데이터를 전달해 준다.)

JSON으로 받아온 데이터를 내 필요에 맞게 형태를 변경하는 작업을 파싱이라 할 수 있겠다.

아래 포스팅에 더욱 자세하게 정리되어 있다 :)

https://toughie-ios.tistory.com/113

 

JSON Parsing

JSON이란? JavaScriptObjectNotation의 약자. JSON의 문법이 자바스크립트 문법과 유사하지만 자바스크립트에서만 사용되는 것이 아니라 JSON Parsing을 지원하는 프로그래밍 언어에서는 다 사용할 수 있다.

toughie-ios.tistory.com

[데이터 구조(파싱)]

해당 프로젝트 코드의 경우에는

게시글(Feed) 구조체 안에

글쓴이(유저 이름, 프로필 사진), 컨텐츠(텍스트, 관련 이미지)가 

중첩타입으로 설계되어 있다.

이외에 시간과, 좋아요 수도 존재한다.

-> 다른 api를 통해 json을 받아온다면 parser 사이트를 이용하든, 직접 커스텀하든 

key-value 관계를 잘 파악해서 만들 수 있겠다.

 

또한 extension을 통해 타입 계산 속성을 만들고 여기서 JSON 파싱을 진행한다.

[게시글]의 형태로 리턴된다.

이 프로젝트에서는 네트워킹을 통해 json을 받아온 것이 아닌, 로컬에 저장된 json 파일을 활용했다.(Assets.xcassets)

따라서 사용된 관련 프로퍼티, 메소드들에 대해 간략하게 알아보고 넘어가자.

 

https://developer.apple.com/documentation/uikit/nsdataasset

 

NSDataAsset | Apple Developer Documentation

An object from a data set type stored in an asset catalog.

developer.apple.com

An object from a data set type stored in an asset catalog.

말 그대로 에셋 카탈로그에 있는 데이터셋 타입의 객체 클래스이다. 아래는 활용 예시

gaurd let dataAsset: NSDataAsset = NSDataAsset(name: "testFile") else {
print("파일 이상")
return [] }

 

https://developer.apple.com/documentation/foundation/jsondecoder/keydecodingstrategy

 

JSONDecoder.KeyDecodingStrategy | Apple Developer Documentation

The values that determine how to decode a type’s coding keys from JSON keys.

developer.apple.com

The values that determine how to decode a type’s coding keys from JSON keys.

let jsonDecoder: JSONDecoder = JSONDecoder()
jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase

-> 코딩키를 디코딩 할 때 스네이크케이스를 카멜케이스로 바꿔서 디코딩하겠다.

 

디코드 단계

https://developer.apple.com/documentation/foundation/jsondecoder/2895189-decode

 

decode(_:from:) | Apple Developer Documentation

Returns a value of the type you specify, decoded from a JSON object.

developer.apple.com

JSON데이터 어떤 형태로 디코딩 할 것인가? 여기서 메타 타입이 사용된다. T.Type

아래는 예시 코드이다.

let jsonDecoder: JSONDecoder = JSONDecoder()
jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase

do {
	let datas: [타입] = try jsonDecoder.decode([타입].self, from 데이터)
    return datas
    } catch {
    print(error.localizedDescription)
    return []
    }

[테이블셀]

테이블뷰 위에 올릴 셀을 만든다. 

(UITableViewCell을 상속한 클래스)

해당 셀을 그리는 것은 함수를 만들어서 생성자에서 실행한다.

 

해당 셀에는

프로필 이미지,

글쓴이,

시간,

피드 내용(컨텐츠),

피드 사진

좋아요 이미지

좋아요 개수

정도가 들어있다.

 

생성자 파트 코드 (커스텀 뷰이기에 required init 필요)

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        drawCell()
    }
    
        required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

[drawCell () 구성 ]

각각 컴포넌트의 레이아웃으로 바로바로 잡아주기 보다는

스택에 담아서 묶어두고 나중에 레이아웃을 잡는 것이 좀 더 간단하고 확실한 방법같다.

 

먼저 크게는 [프로필 이미지, 글쓴이 이름, 시간]을 HStack으로 묶어준다.

-> 프로필스택으로 지칭

프로필 스택안의 컴포넌트들에서 살펴볼만한 내용들을 정리해보려 한다.

[프로필 스택]

[이미지 관련 설정]

        //시스템 기본 이미지(SF Symbols)
        profileImageView = UIImageView(image: UIImage(systemName: "person.fill"))
        
        //subView가 view의 경계를 넘어가면 잘림(true인 경우)
        //보통 image.layer.cornerRadius = 20등과 같이 모서리 둥글게 하는 코드와 함께 사용
        profileImageView.clipsToBounds = true
        
  
        profileImageView.contentMode = .scaleAspectFit
        /*
        컨텐츠 모드에는 다양한 케이스가 있지만, 대표적인 3가지에 대해 간단하게 보자
        scaleToFill - View의 크기와 일치하도록 높이와 너비가 늘어남(꽉 참) 비율 왜곡 생길 수 있음.
        
        
        AspectFit - 너비, 높이 중 긴 쪽이 뷰와 일치하도록 늘어남.
        -> 높이나 너비의 왜곡 없이 가능한 선에서 이미지를 크게 만듦
        
        
        AspectFill - 너비, 높이 중 짧은 쪽이 뷰와 일치하도록 늘어남.
        -> 원래 이미지의 비율의 왜곡 없음(대신 짤릴 수 있음_일부분만 크게)
	*/

[레이블 관련 설정]

      	myLabel = UILabel()
        //접근성_시스템 폰트 크기 대응
        myLabel.adjustsFontForContentSizeCategory = true
        myLabel.font = UIFont.preferredFont(forTextStyle: .caption1)
        myLabel.textColor = .black
        //해당 레이블의 레이아웃이 다른 뷰에 간섭을 받아서는 안되는 경우 
        myLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
        myLabel.setContentCompressionResistancePriority(.required, for: .vertical)
        //레이블 길이에 맞게 허깅하도록
        myLabel.setContentHuggingPriority(.required, for: .horizontal)
        myLabel.text = "Toughie"
        
        //레이블 길이가 길어지는 경우
        //label.numberOfLines = 0

[컨텐츠 레이블, 컨텐츠 이미지]

컨텐츠 레이블, 컨텐츠 이미지를 생성해준다.

imageView.isUserInteractionEnabled = true
//이미지를 탭하면 늘어나는 기능을 구현하기 위해 isUserInteractionEnable 프로퍼티를 true로

[좋아요 스택]

좋아요 버튼과 좋아요 개수(레이블)을 HStack으로 묶어준다.

        //커스텀 이미지 or 기본 이미지 사용
        let likesImageView = UIImageView(image: UIImage(systemName: "hand.thumbsup.fill"))
        likesImageView.tintColor = .systemBlue
        likesImageView.contentMode = .scaleAspectFit
        //옆 좋아요 레이블 영향을 받아 사이즈가 변경되지 않도록 허깅 우선도를 required로
        likesImageView.setContentHuggingPriority(.required, for: .horizontal)

 

[버튼 스택]

버튼 스택을 만들고 forEach를 사용해 버튼을 생성해 스택에 올린다.

        let buttonStack = UIStackView()
        buttonStack.axis = .horizontal
        buttonStack.distribution = .fillEqually
        buttonStack.alignment = .center
        
        ["버튼1", "버튼2", "버튼3"].forEach { title in
            let button = UIButton(type: .system)
            //배열의 요소를 순차적으로 버튼의 타이틀로
            button.setTitle(title, for: .normal)
            button.tintColor = .darkGray
            button.layer.borderWidth = 2
            button.layer.borderColor = UIColor.gray.cgColor
            //스택에 버튼들 추가
            buttonStack.addArrangedSubview(button)
        }

[전체 스택]

프로필스택, 컨텐츠 레이블, 컨텐츠 이미지, 좋아요 스택, 버튼 스택을 VStack으로 묶어줌

        let contentStack = UIStackView(arrangedSubviews: [profileStack, contentTextLabel, contentImageView, likesStack, buttonStack])
        //오토레이아웃을 코드로 잡아주기 위해 꼭 필요한 설정
        contentStack.translatesAutoresizingMaskIntoConstraints = false
        
        contentStack.axis = .vertical
        contentStack.alignment = .fill
        contentStack.distribution = .fill
        
        contentStack.spacing = UIStackView.spacingUseSystem
        //셀의 contentView에 올려준다.
        //contentView는 셀에서 표시하는 컨텐츠의 default superView임.
        contentView.addSubview(contentStack)

[오토 레이아웃 잡아주기]

전체 스택의 탑, 리딩, 바텀, 트레일링 앵커 사용해서 레이아웃 잡아주기.(contentView에 대해)

 

프로필 이미지의 너비 == 높이 /  프로필 이미지의 너비 -> contentView 너비의 10%

 

[컨텐츠 이미지 레이아웃]

       //너비 == 높이 -> 정사각형
       let squareConstraint = contentImageView.widthAnchor.constraint(equalTo: contentImageView.heightAnchor)
        squareConstraint.isActive = true
       //탭 하면 이미지 사이즈 변경을 위해 우선도 required가 아닌 defaultHigh로 설정
       squareConstraint.priority = .defaultHigh

[좋아요 이미지 레이아웃]

        //좋아요 이미지(엄지 척)의 높이는 옆의 좋아요 레이블보다 작거나 같아야함.
        let likesHeight = likesImageView.heightAnchor.constraint(lessThanOrEqualTo: likesLabel.heightAnchor)
        likesHeight.priority = .defaultHigh
        likesHeight.isActive = true
        //최소 높이 30이상, 너비 높이 동일
        likesImageView.heightAnchor.constraint(greaterThanOrEqualToConstant: 30).isActive = true
        likesImageView.widthAnchor.constraint(equalTo: likesImageView.heightAnchor).isActive = true

[컨텐츠 이미지를 탭했을 때 원래 사이즈로 보이도록 구현하는 부분]

//컨텐츠 이미지 제약을 변경하기 위한 변수 설정
private var imageRatioConstraint: NSLayoutConstraint?

var feed: Feed? {
//프로퍼티 옵저버를 통해 셀 내용 변경
	didSet {
    	guard let feed = feed else { return }
    	이미지
        글쓴이
        시간
        컨텐츠 레이블
        컨텐츠 이미지
        좋아요 레이블
        
        //컨텐츠 내용이나, 이미지가 없으면 숨기기 위한 코드
        contentTextLabel?.isHidden = contentTextLabel?.text?.isEmpty == true
        contentImageView?.isHidden = contentImageView?.image == nil
        
        //이미지가 있는 경우 원래 이미지 비율에 맞게 너비, 높이 제약을 
        //imageRatioConstraint에 할당
        if let image = contentImageView.image {
                imageRatioContraint = contentImageView.heightAnchor.constraint(equalTo: contentImageView.widthAnchor,
                multiplier: image.size.height / image.size.width)
            }
        // 이미지를 탭하기 전에는 정사각형 형태로 표시되어야 하기에, 이를 위한 코드
        if let contentImageRatioConstraint = imageRatioContraint {
                contentImageRatioConstraint.isActive = false
                contentImageView.removeConstraint(contentImageRatioConstraint)
            }
        }
    }

[컨텐츠 이미지에 탭 제스쳐 추가]

private func drawCell() {
...
//탭 제스처 초기화 후 컨텐츠 이미지뷰에 add
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(tapImageView))
contentImageView.addGestureRecognizer(tapGesture)
}

@objc private func tapImageView(_ sender: UITapGestureRecognizer) {
        guard let constraint = imageRatioContraint else { return }
        //이미지 비율대로 보이게 constraint.isActive.toggle()
        constraint.isActive.toggle()
        
        UIView.animate(withDuration: 0.3) {
            self.layoutIfNeeded()
        }

//셀 애니메이션 구현을 위해 NotificationCenter를 통해 알림을 날리고 뷰컨트롤러에서 받아줌
NotificationCenter.default.post(name: NSNotification.Name("NeedsToUpdateLayout"), object: nil)
}

이후 뷰컨트롤러의 viewDidLoad에서 observer를 추가해줌

    override func viewDidLoad() {
        super.viewDidLoad()
        
        NotificationCenter.default.addObserver(forName: NSNotification.Name("NeedsToUpdateLayout"),
                                               object: nil,
                                               queue: nil) { [self] Notification in
            tableView?.beginUpdates()
            tableView?.endUpdates()
            //테이블뷰 세팅 아래에 후술
        }
    }

[뷰 컨트롤러]

 

네비게이션 바 세팅

    //네비게이션 바 설정
    private func configureNavigationBar() {
        //네비바 색깔
        navigationController?.navigationBar.barTintColor = .systemBlue
        //네비바 아이템 색깔
        navigationController?.navigationBar.tintColor = .black
        let appearance = UINavigationBarAppearance()
        appearance.configureWithTransparentBackground()
        navigationController?.navigationBar.scrollEdgeAppearance = appearance
        
        //네비게이션 아이템
        let cameraButton = UIBarButtonItem(systemItem: .camera)
        let shareButton = UIBarButtonItem(systemItem: .action)
        
        navigationItem.rightBarButtonItem = cameraButton
        navigationItem.leftBarButtonItem  = shareButton
        
        //서치바
        let searchBar: UISearchBar = UISearchBar()
        searchBar.placeholder = "검색"
        searchBar.searchTextField.backgroundColor = .white
        //https://developer.apple.com/documentation/uikit/uinavigationitem/1624935-titleview
        //서치바가 네비게이션바 센터로
        //custom view that displays in the center of the navigation bar when the receiver is the top item.
        navigationItem.titleView = searchBar
    }

테이블뷰 세팅

class ViewController: UIViewController {

	private var tableVeiw: UITableView?
    
    override func viewDidLoad() {
    	super.viewDidLoad()
        addTable()
        configureTable()
    }
  	//테이블뷰 추가
    private func addTable() {
        tableView = UITableView()
        guard let tableView = self.tableView else { return }
        //잊지말자 addSubview
        view.addSubview(tableView)
        
        //https://developer.apple.com/documentation/uikit/uiscrollview/3198043-automaticallyadjustsscrollindica
        //테이블 뷰의 콘텐츠가 레이아웃 될 때 스크롤 인디케이터 위치가 콘텐츠가 일치하도록 자동으로 조정됨
        tableView.automaticallyAdjustsScrollIndicatorInsets = true
        //테이블뷰 오토레이아웃 코드로 잡기 위해
        tableView.translatesAutoresizingMaskIntoConstraints = false
        
        //테이블뷰 오토레이아웃
        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(equalTo: view.topAnchor),
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
        ])
    }
    

    //테이블뷰 세팅
    private func configureTable() {
        guard let tableView = self.tableView else { return }
        
        //테이블 뷰 셀 크기를 동적으로 조정하기 위한 코드
		//각 셀의 높이가 자동으로 계산됨, 셀 내용의 크기에 따라 적절한 높이 설정됨.
        tableView.rowHeight = UITableView.automaticDimension
        
        //https://developer.apple.com/documentation/uikit/uitableview/1614925-estimatedrowheight
        //테이블 뷰의 높이를 계산하기 위한 추정치로 사용됨.
        //테이블 뷰가 셀의 실제 높이를 계산하기 전에 각 셀의 높이 대략적으로 추정 -> 테이블 뷰 성능 향상
        tableView.estimatedRowHeight = UITableView.automaticDimension
        
        // -> 테이블 뷰의 높이를 동적으로 조정해 셀의 내용에 맞게 최적화, 테이블 뷰 성능향상을 위해 테이블 뷰 셀 높이 추정
        
        //셀 등록
        //테이블 뷰가 재사용할 셀 섹별자 등록 (화면에 보이는 만큼+a로 셀을 그리고 계속 내용물만 바꾸는 방식)
        tableView.register(FeedTableViewCell.self, forCellReuseIdentifier: "cell")
        
        //DataSource
        //테이블 뷰에 표시할 데이터를 제공하는 객체(여기서는 뷰컨)
        // -> UITableViewDataSource 프로토콜 채택해야함.(보통 extension에서)
        tableView.dataSource = self
        
        //셀 구분선
        tableView.separatorStyle = .singleLine
        //구분선 여백
        tableView.separatorInset = .zero
    }
}
extension ViewController: UITableViewDataSource {
    //https://developer.apple.com/documentation/uikit/uitableviewdatasource/1614931-tableview
    //테이블뷰의 섹션에 표시될 셀의 개수를 반환하는 메서드. (테이블 뷰에 10개의 셀을 표시함)
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        10
    }
    
    //https://developer.apple.com/documentation/uikit/uitableviewdatasource/1614861-tableview
    //각 셀에 대한 정보를 반환하는 메소드
    //테이블 뷰에서 재사용할 셀을 가져옴 -> FeedTableViewCell로 다운캐스팅, 이 셀의 속성인 feed 세팅
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as? FeedTableViewCell
        else { return tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
            
        }
        //예시 데이터가 10개여서 랜덤으로 
        cell.feed = Feed.feeds[Int.random(in: 0...9)]
        return cell
    }
}

 

 

간단해 보이지만

많은 개념들과 중요한 부분들이 많아서(테이블뷰, 셀, 셀의 contentView, JSON 파싱 등) 정리하는데 꽤 오래 걸렸다.

 

[학습 소스]

야곰 오토레이아웃 정복하기 강의

https://yagom.net/courses/autolayout/https://yagom.net/courses/autolayout/

애플 공식문서

https://developer.apple.com/documentation/uikit/

참고 블로그

https://silver-g-0114.tistory.com/107

https://zeddios.tistory.com/800

https://hyerios.tistory.com/35

https://songios.tistory.com/43