Aug 25, 2021, TIL (Today I Learned) - 이미지 전환과 UIPageControl 그리고 데이터 전달

Inwoo Hwang·2021년 8월 26일
1

TIL

목록 보기
1/8
post-thumbnail

학습내용


오픈마켓 프로젝트를 진행하면서 사용자가 상품을 클릭하면 아래와 같이 상품 사진이 뜨게 하면서 동시에 사진을 스와이프 하여 판매자가 올려놓은 여러 개의 사진을 보여주는 상품 상세화면을 구현하려 했습니다.

특히 밑에 보이는 사진 갯수를 표현하는 점들이 인상적이네요.

어떻게 구현해야 하나 이리저리 찾아보다가 이 영상 을 보고 UIPageControl 을 활용하면 되겠다라는 생각이 들어서 바로 공부 해 보았습니다.

UIPageControl 이란?

A control that displays a horizontal series of dots, each of which corresponds to a page in the app's document or other data-model entity.

공식문서의 개요를 이해해 보자면 유저가 page control을 텝 하여 다음 또는 전 페이지로 넘어가려할 경우, UIPageControl은 valueChanged 이벤트를 delegate에게 처리하라고 시킨다고 하네요. delegate는 currentPage 프로퍼티를 체크한 뒤 어떤 페이지를 보여줄지 정한다고 합니다. page control은 한 번에 한 방향으로 진행되고 현재 보여지는 페이지는 하얀색 점으로 표시된다고 합니다.

이 친구도 대리자를 통해서 이벤트 처리를 하는군요. 바로 한 번 사용 해 봐야겠습니다.

먼저 이미지를 담을 collectionViewcell이 필요하겠네요.

class OpenMarketDetailedItemViewController: UIViewController {
private var imageSliderCollectionView: UICollectionView = {
        let height = UIScreen.main.bounds.height
        let width = UIScreen.main.bounds.width
        let layout = UICollectionViewFlowLayout()
        
        layout.scrollDirection = .horizontal
        layout.itemSize = CGSize(width: width, height: height)
        layout.sectionInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
        layout.minimumLineSpacing = 0
        layout.minimumInteritemSpacing = 0
        
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.register(ImageSliderCollectionViewCell.self, forCellWithReuseIdentifier: ImageSliderCollectionViewCell.identifier)
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        collectionView.showsHorizontalScrollIndicator = false
        
        return collectionView
    }()
}

flowlayout 을 활용하여 collectionView 의 스크롤 방향, cell의 사이즈와 위 아래 간격을 설정 해 주었어요.

그 다음 collectionView 의 환경설정을 위와 같이 설정 해 주었어요. custom cell 등록을 한 뒤 시스템의 autoresizingMask 의 제약사항과 충돌을 방지하기 위해 translatesAutoresizingMaskIntoConstraints 를 false로 설정 했습니다. 마지막으로 옆으로 드래그할 때 보이는 스크롤바도 제거 해 주었습니다.

이제 이미지를 받아와야겠네요.

class OpenMarketViewController: UIViewController {
  private var openMarketItems: [OpenMarketItem] = []
  
}

// MARK: - UICollectionViewDelegate

extension OpenMarketViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        let detailedItemViewController = OpenMarketDetailedItemViewController()
        navigationController?.pushViewController(detailedItemViewController, animated: false)
        self.sendImages(openMarketItems, index: indexPath.item) { images in
            detailedItemViewController.sliderImages = images
        }
    }
  private func sendImages(_ items: [OpenMarketItem], index: Int, completion: @escaping ([UIImage]) -> ()) {
        var downloadedImages: [UIImage] = []
        
        let downloadedImageURLStrings = items[index].thumbnails
        
        downloadedImageURLStrings.forEach { string in
            guard let imageURL = URL(string: string) else { return }
            let downloadedimage = downloadImage(url: imageURL)
            downloadedImages.append(downloadedimage)
        }
        completion(downloadedImages)
    }
    
    private func downloadImage(url: URL) -> UIImage {
        guard let data = try? Data(contentsOf: url),
              let image = UIImage(data: data) else { return UIImage() }
        return image
        
    }
}

네트워킹 작업을 통해 받아온 [OpenMarketItem] 의 thumbnailURL을 통해서 필요한 이미지를 가져와야 하기에 downladImage(url: URL) 메서드를 구현하였구요. 다운받은 이미지를 상세화면으로 전달한 상태에서 OpenMarketDetailedItemViewController() 의 이미지가 업데이트 되어야 하기에 sendImages(_ items: _index:, completion:) 의 escaping closure를 활용하여 완전히 받아온 이미지들을 OpenMarketDetailedItemViewController()sliderImages 라는 이미지 배열에 담아주었습니다.

이제 상세화면의 틀을 잡아봐야겠네요.

class OpenMarketDetailedItemViewController: UIViewController {
  
  override func viewDidLoad() {
        super.viewDidLoad()
        imageSliderCollectionView.dataSource = self
        setUpUIConstraint()
    }
    
    private func setUpUIConstraint() {
        self.view.addSubview(imageSliderCollectionView)
        
        NSLayoutConstraint.activate([
            imageSliderCollectionView.topAnchor.constraint(equalTo: self.view.topAnchor),
            imageSliderCollectionView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
            imageSliderCollectionView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
            imageSliderCollectionView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: -200)
        ])
    }
}

collectionView의 제약사항도 대충 잡아보고

extension OpenMarketDetailedItemViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        sliderImages.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ImageSliderCollectionViewCell.identifier, for: indexPath) as? ImageSliderCollectionViewCell else {
            return UICollectionViewCell()
        }
        cell.setUpImage(sliderImages, index: indexPath.item)
        return cell
    }
    
}

collectionView 에 보여줄 celldataSource 에 구현해 놓은 다음

class ImageSliderCollectionViewCell: UICollectionViewCell {
    static let identifier = "ImageSliderCollectionViewCell"
    
    private var detailedImageView: UIImageView = {
        let imageView = UIImageView()
        imageView.translatesAutoresizingMaskIntoConstraints = false
        imageView.contentMode = .scaleAspectFit
        imageView.clipsToBounds = true
        return imageView
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setUpUIConstraint()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }
    
    func setUpUIConstraint() {
        self.contentView.addSubview(detailedImageView)
        
        NSLayoutConstraint.activate([
            detailedImageView.topAnchor.constraint(equalTo: self.contentView.topAnchor),
            detailedImageView.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor),
            detailedImageView.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor),
            detailedImageView.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor)
        ])
    }
    
    func setUpImage(_ images: [UIImage], index: Int) {
        detailedImageView.image = images[index]
    }
}

cell의 제약사항도 위와 같이 간단하게 잡아봤어요. cell에 있는 imageView의 크기를 cell의 크기와 같게하면 얼추 수평으로 의도한 이미지들만드로 cell을 구성할 수 있겠네요.

한 번 돌려보면?!?

얼추 의도한대로 상품목록이 보이긴 하는데.. 문제가 생겼어요.

TroubleShooting

레이아운 문제...하 레이아웃문제는 맞닥드리면 항상 어질어질한것 같아요.

문제를 천천히 읽어보니..the item height must be less than the height of the UICollectionView minus the section insets top and bottom values, minus the content insets top and bottom values.

조금 해석 해 보자면..cell의 크기가 UICollectionView보다 크면 안된다라는 warning인 것 같네요.

cell 의 height를 어떻게 잘못 주었나 살펴보니...

class OpenMarketDetailedItemViewController: UIViewController {
private var imageSliderCollectionView: UICollectionView = {
        let height = UIScreen.main.bounds.height
        let width = UIScreen.main.bounds.width
        let layout = UICollectionViewFlowLayout()
        
        layout.scrollDirection = .horizontal
        layout.itemSize = CGSize(width: width, height: height)
        layout.sectionInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
  .
  .
  .

이 부분이 문제였네요. 정말 바보 같은 실수를 했습니다. flowlayout.itemSize를 줄 때 height를 스크린의 height로 주어버렸네요. 이러면 오류가 나는게 당연합니다;

private func setUpUIConstraint() {
        self.view.addSubview(imageSliderCollectionView)
        
        NSLayoutConstraint.activate([
            imageSliderCollectionView.topAnchor.constraint(equalTo: self.view.topAnchor),
            imageSliderCollectionView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
            imageSliderCollectionView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
            imageSliderCollectionView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: -200)
        ])
    }

collectionView의 제약사항을 줄 때 bottom 부분을 superView bottom에 -200만큼 주었는데 item 즉 cell의 높이를 화면 전체 높이로 줘버렸으니 두 제약사항이 충돌하면서 xcode가 정신을 못차린거죠..ㅎ;

let height = CGFloat(200)

height를 일단 임시로 200만큼 주었더니

이제 conflit 메세지가 사라졌네요. UI는 추후에 손볼거지만 정상적으로 문제 없이 화면이 보이니 일단 기쁩니다 ㅎㅎ

UIPageControl을 한 번 만들어보자!

계속 해서 이제는 UIPageControl 을 한번 사용해 볼거에요.

private lazy var imageSlider: UIPageControl = {
        let pageControl = UIPageControl()
        pageControl.translatesAutoresizingMaskIntoConstraints = false
        pageControl.numberOfPages = sliderImages.count
        pageControl.hidesForSinglePage = true
        pageControl.currentPageIndicatorTintColor = .systemGray
        pageControl.pageIndicatorTintColor = .systemGray3
        return pageControl
    }()

해당 imageSliderlazy var 로 만들어줬습니다. 그 이유는 lazy 가 없는 변수의 초기화는 static하게 이뤄지고 이 시점에서는 뷰컨트롤러의 인스턴스 프로퍼티를 참조할 수 없기 때문에 lazy 하게 변수를 설정 해 주었습니다.

pageControl에서 보여야 하는 동그라미 갯수는 이미지의 갯수와 같아야 하니

pageControl.numberOfPages = sliderImages.count 로 설정 해 주었구요

이미지의 갯수가 한 개일 때에는 굳이 pageControl 을 화면에 띄울 필요 없어서 hidesForSiinglePage 설정을 해 주었습니다.

이렇게 page control을 하나 만들어 봤는데요.

UIPageControl 동작 구현

이제 동작을 구현 해 봐야겠죠?

유저가 사진을 옆으로 스와이프 해서 다음 사진을 화면에 띄우면 사진 밑에 존재하는 pageControl의 현재 페이지도 바뀌어야 겠죠?

이 블로그 에서 해당 부분에 대해서 너무 잘 설명 되어 있어서 참고하여 가져와 봤습니다.

UICollectionViewDelegate 프로토콜이 UIScrollViewDelegate 프로토콜을 상속 받고 있어서 다양한 스크롤 시점에 대한 이벤트 처리 또한 UICollectionViewDelegate 를 채택한 객체가 처리할 수 있더라구요.

그래서 스크롤의 드래그가 끝나기 직전 에 아래와 같은 코드 구현을 해 주었습니다.

extension OpenMarketDetailedItemViewController: UICollectionViewDelegate {
    func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
        let nextPage = Int(targetContentOffset.pointee.x / self.view.frame.width)
        
        self.imageSlider.currentPage = nextPage
        
    }
}

UIPageControl의 page 원리 이해

아직 개념이 부족해서 Int(targetContentOffset.pointee.x / self.view.frame.width) 이 공식이 잘 이해가 안되더라구요. 그래서 열심히 print 를 찍어서 드디어 이해가 되었습니다.

*targetContentOffsetscrollView에 대한 자세한 내용은 여기 를 참고 해 주세요.

해당 collectionView 에서 이 블링블링한 애플워치 스트랩 사진을 드래그 하면서 targetContentOffset이 어떻게 달라지는지 확인 해 봤습니다.

해당 사진은 배열에 담겨져있는 각각 다른 사진이지만 scrollView 의 시점에서는 결국 하나의 큰 그림(contentView)의 일부라는 것을 깨달았습니다.

그렇기 때문에 scroll을 넘길 때 마다 scrollView.bounds.origin 값이 바뀌는 것이고 이는 contentOffset 이 바뀐다는 것을 의미하는 거죠!!

UIPageControl의 page는 배열과 똑같이 0번째 부터 시작 되니까 스크롤이 끝난 시점에서 targetContentOffset.pointee.x 값을 각 view의 width 값으로 나눠주면 몇 번째 page인지 알 수 있는거죠

0.0/ 360.0 = 0.0

360.0 / 360.0 = 1.0

이런식으로 계산이 되는 거였습니다.

이렇게 계산된 CGFloat 값을 Int로 변환한 뒤

self.imageSlider.currentPage = nextPage

이렇게 UIPageControlcurrentPage 에 할당 해 주니 사진들을 스크롤 할 때마다 밑에 있는 점들도 순서대로 움직이는 것을 확인 할 수 있었습니다. 😆

마무리하며

가끔 stackoverflow 또는 블로그에서 계산 공식을 알려주면서 "이렇게 하면 작동 잘 합니다." 라고 적혀있는 글을 볼 때가 있는데요. 복붙 후 약간의 수정을 통해서 원하는 동작을 구현할 수는 없지만 만약 그 원리를 잘 이해하지 못하면 완전히 내것으로 만들 수 없다는 생각에 조금 깊게 알아보는 시간을 가졌어요.

이렇게 공부 해 보니 이제 원리가 이해가 되고 추후에 다른 프로젝트에서도 더 잘 사용할 수 있겠다는 생각에 기분이 좋아졌습니다.😁

조금 시간은 걸리지만 앞으로도 동작만을 구현하는 개발자가 아니라 동작 원리를 이해하는 개발자로 성장하도록 더 열심히 해야겠습니다. 그럼 이만!

profile
james, the enthusiastic developer

0개의 댓글