본문 바로가기

iOS 개발/Unsplash 클론 코딩

Unsplash - 8. 사진 상세 화면(PhotoDetailViewController, Cell)의 메인 로직

반응형

안녕하세요 Skillist에요.

 

이번엔 사진 상세 화면을 살펴볼게요

 

다음은 상세화면이에요.

 

상세화면엔 콜렉션뷰 추가 및 구현하여 좌우 스크롤이 가능합니다.

좌우 스크롤 시 상단의 사진 정보가 변경됩니다.

 

또 화면 터치시 FullScreen Mode로 변경돼요

그리고 화면을 아래로 드래그하면, VC가 사라집니다.

그럼 바로 코드 설명해볼게요

 

다음은 상단의 View를 구현하기 위한 코드입니다.

14라인 : 상단 뷰를 UIView로 구현했습니다.

 

20라인 : 상단 뷰의 가운데에 존재하는 label입니다.

 

28, 36라인 : 상단 뷰 양옆에 존재하는 버튼들이에요.

 

다음은 collectionView입니다.

45라인 : flowlayout을 활용했어요.

51라인 : 기존 사진에서 다음사진으로 이동할 경우, 자석 처럼 착 달라붙어 이동 및 정렬 되는거 본적있죠? 해당 기능을 구현합니다. isPadingEnabled 값을 true로 설정하세요.

 

56라인 : 사진 좌우 스크롤을 구현하기 위해 collectionView와 collectionViewCell를 사용했어요. 이를 위해 cell을 등록합니다.

 

먼저 레이아웃 제약사항들을 설정할게요

view에 상단뷰와 collectionView를 추가합니다.

136라인 : topInfoView인데요. 설정에 조심해야 합니다. 왜냐면 아이폰에는 폼팩터 2가지가 존재하기 때문이죠(2021년 12월 기준)

왼쪽은 TouchID를 제공하는 아이폰, 오른쪽은 FeceID를 제공하는 아이폰이에요.

세가지의 선을 그었는데요, 설명해볼게요.

1번 - SuprerView의 Top입니다.

2번 - SafeArea의 Top입니다.

3번 - 구현하고자 하는 topInfoView의 Bottom입니다.

 

1번과 2번의 height가 다르기 때문에, 구현에 신경써야해요.

그래서 topInfoView의 Top은 1번(SuperView의 Top)으로 설정했고,

topInfoView의 Bottom을 3번(2번 + 50)으로 설정하여, 두가지 유형의 아이폰 모두 적용할수 있도록 구현했어요.

 

141라인 : collectionView는 superView에 맞췄습니다.

 

그럼 topInfoView에 대한 레이아웃 구성을 볼까요

145라인 : topInfoView에 취소버튼, 공유버튼, label을 추가했어요.

titleLabel을 설정하고, 이를 기준으로 cancel, share버튼의 centerY를 설정했습니다.

 

173라인 : 다음은 collectionView에 대한 데이터를 리로드 하고, 

177라인 : 사진 목록 화면에서 선택한 사진으로 이동하게끔 구현했어요

 

194라인 : 콜렉션뷰는 사진 목록의 개수를 반환합니다.

 

198라인 : 사진에 대한 Cell이며, 해당 사진 정보를 넘겨줘 cell을 설정합니다.

 

213라인 : 콜렉션뷰의 contenfOffset을 가져와서, 현재 row를 계산합니다. 이는 사진의 width가 고정돼있어서, 가능한 로직입니다. row를 가져오는 로직에 대해서, 다른 로직들은 정확도가 떨어지던데, 213라인보다 좋은 로직이 있으면 공유 부탁드립니다!.

 

206라인 : 사진 변경이 끝나면(스크롤이 끝나면) row를 가져와, 사진 정보(title)를 업데이트 하고, cell이 변경됐다는 delegate로 전달합니다. 이는 사진목록VC로 전달되어, center로 위치한 사진이 변경됩니다.

 

다음은 cellChangeDelegate에요.

 

PhotoListViewController의 changedCell을 한번 볼게요

detailVC에서 셀이 변경되면, PhotoListViewController의 changedCell이 수행되어, 사진 목록VC의 center에 위치한 사진을 현재 보여주고있는 사진으로 변경합니다.

 

계속해서 detailVC로 돌아와 코드를 살펴볼게요

220라인 : superView에 맞춘 collectionView에 맞게 cell Size를 보여주고 있습니다.

 

이정도가 PhotoDetailViewController의 메인로직이에요

 

실제로 사진을 보여주는 cell인 PhotoDetailCollectionViewCell을 볼까요?

14라인 : 다운가능한 ImageView를 선언했습니다.

 

41라인 : 사진 정보인 photo를 전달받아, cell을 설정합니다.

 

44라인 : width를 기준으로, 사진의 height를 계산하였고, imageView를 설정했어요.

 

Cell은 엄청 간단하죠????????

이게 다 DownloadableImageView 덕분입니다. 이미지 다운로드 코드를 미리 구현해놓은 결과, 코드 재사용이 늘고, 코드는 간결해졌어요!!!!!!!!! 흐흫흐흐

 

제스처에서 설명이 필요하고, 더 설명하고 싶은데, 다음 글에서 설명할게요

 

 

잘못되거나 부족한 내용 등, 피드백 감사합니다!

 

Skillist의 Unsplash 프로젝트

https://github.com/DeveloperSkillist/UnsplashCloneCode

 

GitHub - DeveloperSkillist/UnsplashCloneCode: UnsplashCloneCode

UnsplashCloneCode. Contribute to DeveloperSkillist/UnsplashCloneCode development by creating an account on GitHub.

github.com

 

↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓  전체 코드  ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓

 

import UIKit

class PhotoDetailViewController: UIViewController {
    
    //PhotoListViewController의 목록 위치 변경
    weak var cellChangeDelegate: CellChangeDelegate?
    private lazy var topInfoView: UIView = {
        let topInfoView = UIView()
        topInfoView.backgroundColor = .darkGray
        return topInfoView
    }()
    
    private lazy var titleLabel: UILabel = {
        let uiLabel = UILabel()
        uiLabel.font = .systemFont(ofSize: 25, weight: .bold)
        uiLabel.textColor = .white
        uiLabel.textAlignment = .center
        return uiLabel
    }()
    
    private lazy var cancelButton: UIButton = {
        let button = UIButton()
        button.setImage(UIImage(systemName: "xmark"), for: .normal)
        button.addTarget(self, action: #selector(dismissDetailView), for: .touchUpInside)
        button.tintColor = .white
        return button
    }()
    
    private lazy var shareButton: UIButton = {
        let button = UIButton()
        button.setImage(UIImage(systemName: "square.and.arrow.up"), for: .normal)
        button.addTarget(self, action: #selector(sharePhoto), for: .touchUpInside)
        button.tintColor = .white
        return button
    }()
    
    private lazy var collectionView: UICollectionView = {
        let layout = UICollectionViewFlowLayout()
        layout.scrollDirection = .horizontal
        layout.sectionInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
        layout.minimumLineSpacing = 0
        
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.isPagingEnabled = true
        collectionView.showsHorizontalScrollIndicator = false
        collectionView.backgroundColor = .black
        collectionView.dataSource = self
        collectionView.delegate = self
        collectionView.register(PhotoDetailCollectionViewCell.self, forCellWithReuseIdentifier: "PhotoDetailCollectionViewCell")
        collectionView.layoutIfNeeded()
        return collectionView
    }()
    
    var photos: [Photo] = []
    var startRow = 0
    
    //fullScreen여부에 따라 일부 view hide or show
    private var isFullscreen = false {
        willSet {
            if !newValue {
                topInfoView.isHidden = newValue
            }
            
            UIView.animate(withDuration: 0.5, animations: {
                if newValue {
                    self.topInfoView.alpha = 0.0
                } else {
                    self.topInfoView.alpha = 1.0
                }
            }) { [weak self] success in
                self?.topInfoView.isHidden = newValue
            }
        }
        
        didSet {    //상단바와 하단 홈바 hide or show
            self.setNeedsStatusBarAppearanceUpdate()
            self.setNeedsUpdateOfHomeIndicatorAutoHidden()
        }
    }
    
    //VC를 pullDown하여 VC 종료 중 일부 view hide or show
    private var isVCDismissing: Bool = false {
        willSet {
            if newValue {
                topInfoView.isHidden = true
                topInfoView.isHidden = true
                view.backgroundColor = .clear
            } else {
                topInfoView.isHidden = isFullscreen
                topInfoView.isHidden = isFullscreen
                view.backgroundColor = .black
            }
        }
    }
    //pullDown에 대한 Y위치를 저장
    private var viewPullDownY: CGFloat = 0
    
    @objc func dismissDetailView() {
        self.dismiss(animated: false, completion: nil)
    }
    
    @objc func sharePhoto() {
        var shareObjects: [Any] = []
        shareObjects.append("unsplash_share_title".localized)
        shareObjects.append(photos[currentItemRow].links.html)
        
        let activityViewController = UIActivityViewController(activityItems: shareObjects, applicationActivities: nil)
        activityViewController.popoverPresentationController?.sourceView = self.view
        self.present(activityViewController, animated: true, completion: nil)
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()

        setupLayout()
        setupGesture()
    }
    
    private func setupLayout() {
        view.backgroundColor = .black
        
        [
            collectionView,
            topInfoView
        ].forEach {
            view.addSubview($0)
        }
        
        topInfoView.snp.makeConstraints {
            $0.top.leading.trailing.equalToSuperview()
            $0.bottom.equalTo(view.safeAreaLayoutGuide.snp.top).offset(50)
        }
        
        collectionView.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
        
        [
            cancelButton,
            shareButton,
            titleLabel
        ].forEach {
            topInfoView.addSubview($0)
        }
        
        cancelButton.snp.makeConstraints {
            $0.leading.equalTo(topInfoView).inset(20)
            $0.centerY.equalTo(titleLabel)
            $0.width.height.equalTo(20)
        }
        
        shareButton.snp.makeConstraints {
            $0.trailing.bottom.equalTo(topInfoView).inset(20)
            $0.centerY.equalTo(titleLabel)
            $0.width.height.equalTo(20)
        }
        
        titleLabel.snp.makeConstraints {
            $0.top.equalTo(view.safeAreaLayoutGuide.snp.top)
            $0.leading.equalTo(cancelButton.snp.trailing).offset(10)
            $0.trailing.equalTo(shareButton.snp.leading).offset(-10)
            $0.bottom.equalTo(topInfoView.snp.bottom).inset(10)
        }
        
        titleLabel.text = photos[startRow].user.name
        collectionView.reloadData()
        collectionView.layoutIfNeeded()
        
        //PhotoListViewController에서 선택한 아이템을 보여주기
        collectionView.scrollToItem(at: IndexPath(row: startRow, section: 0), at: .centeredHorizontally, animated: false)
    }
    
    override var preferredStatusBarStyle: UIStatusBarStyle {
        return .lightContent
    }
    
    override var prefersStatusBarHidden: Bool {
        return isFullscreen
    }
    
    override var prefersHomeIndicatorAutoHidden: Bool {
        return isFullscreen
    }
}

extension PhotoDetailViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return photos.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "PhotoDetailCollectionViewCell", for: indexPath) as? PhotoDetailCollectionViewCell else {
            return UICollectionViewCell()
        }
        cell.setup(photo: photos[indexPath.row])
        return cell
    }

    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        //사진을 변경(스크롤이 끝나면)한 경우 현재 row를 가져와 상단의 사진 정보 변경
        let row = currentItemRow
        titleLabel.text = photos[row].user.name
        cellChangeDelegate?.changedCell(row: row)
    }
    
    var currentItemRow: Int {
        //현재 row 계산하기
        return Int(collectionView.contentOffset.x / collectionView.frame.size.width)
    }
}

extension PhotoDetailViewController: UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        return CGSize(width: collectionView.frame.width, height: collectionView.frame.height)
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
        return 0
    }
}

//MARK: Gesture
extension PhotoDetailViewController {
    func setupGesture() {
        //fullScreen toggle 제스처 등록
        let fullscreenGesture = UITapGestureRecognizer(target: self, action: #selector(toggleFullScreen))
        self.view.addGestureRecognizer(fullscreenGesture)
        
        //VC PullDown 제스처 등록
        let pullDownGesture = UIPanGestureRecognizer(target: self, action: #selector(pullDownDismissGesture(sender:)))
        self.view.addGestureRecognizer(pullDownGesture)
    }
    
    //fullScreen toggle 제스처
    @objc func toggleFullScreen() {
        isFullscreen.toggle()
    }
    
    //VC PullDown 제스처
    @objc func pullDownDismissGesture(sender: UIPanGestureRecognizer) {
        switch sender.state {
            
        //제스처가 변경중인 경우
        case .changed:
            viewPullDownY = sender.translation(in: view).y
            if viewPullDownY < 0 {  //VC를 위로 올릴 경우, dismiss 수행을 하지 않기 위해서 break
                break
            }

            isVCDismissing = true   //PullDown 진행중인 경우 일부 view hide
            UIView.animate(
                withDuration: 0.5,
                delay: 0,
                usingSpringWithDamping: 0.7,
                initialSpringVelocity: 1,
                options: .curveEaseOut,
                animations: {
                    self.view.transform = CGAffineTransform(translationX: 0, y: self.viewPullDownY)
                    self.view.alpha = 1 - (self.viewPullDownY / (self.view.bounds.height * 0.7))    //alpha  변경
                }
            )

        //제스처가 끝난 경우
        case .ended:
            //pullDown의 위치를 확인하여, 일정 이동한 경우 dismiss 수행
            if viewPullDownY >= 200 {
                dismiss(animated: true)
                break
            }

            //dismiss를 수행하지 않을 경우, alpha 변경 및 일부 view show
            isVCDismissing = false
            UIView.animate(
                withDuration: 0.5,
                delay: 0,
                usingSpringWithDamping: 0.7,
                initialSpringVelocity: 1,
                options: .curveEaseOut,
                animations: {
                    self.view.transform = .identity
                    self.view.alpha = 1
                }
            )

        default:
            break
        }
    }
}
import UIKit

class PhotoDetailCollectionViewCell: UICollectionViewCell {
    
    var photo: Photo?
    
    private lazy var imageView: DownloadableImageView = {
        let imageView = DownloadableImageView(frame: .zero)
        imageView.contentMode = .scaleAspectFill
        return imageView
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        setupLayout()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupLayout() {
        self.addSubview(imageView)
        
        imageView.snp.makeConstraints {
            $0.leading.trailing.equalToSuperview()
            $0.centerX.equalToSuperview()
            //BUG: PhotoListViewController와 PhotoDetailViewController의 사진 vertical center가 일치하지 않아, offset 설정
            $0.centerY.equalToSuperview().offset(-18)
        }
    }
    
    func setup(photo: Photo) {
        self.photo = photo
        
        let cellHeight = photo.imageRatio * contentView.frame.width
        imageView.snp.makeConstraints {
            $0.height.equalTo(cellHeight)
        }
        imageView.downloadImage(url: photo.urls.regular)
    }
}
반응형