본문 바로가기

iOS 개발/Unsplash 클론 코딩

Unsplash - 12. Search 화면

반응형

안녕하세요. Skillist입니다~~

오랜만에 찾아왔어요. 주말엔 집중하여 개발하기 좋아, 주말에는 개발을, 주중에는 글작성을 하고 있어요.

 

오늘은 주말동안 개발한 Search 화면에 대해서 작성해볼게요.

사실은 Search 화면 구현하기 싫어서, 새로운 클론코딩 프로젝트를 진행하다가, Search까지 구현하고자 다시 마음 먹고 구현했어요.

아직 미완성 코드지만, 메인 로직은 구현했기 때문에, 소개해볼게요. 수정 사항이 발생하면, 추후 새로 글 작성할게요.

현재 코드는 다음 레이아웃을 갖게됩니다.

먼저 Search화면은 navigationBar를 가지고 있어요. 그래서 NavigationViewController을 사용하여 TabBarItem을 구현했어요

navigationBar의 rootViewController은 실제로 보여줄 SearchMainViewController로 지정했습니다.

 

 

navigationViewController을 볼까요?

저는 navigationBar 색상 지정을 위해서 NavigationBarApprearance를 사용했습니다.

 

다음은 SearchMainViewController입니다.

여러분 다음 레이아웃을 구현하기 위해서 collectionView를 몇개 사용할까요?

참고로, Browse by Category는 가로로 스크롤 되며, Discover는 세로로 스크롤 됩니다.

결론은 1개 입니다. 

NSCollectionLayoutSection를 통해, collectionView의 section을 나눠, 구현을 합니다.

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

 

Apple Developer Documentation

 

developer.apple.com

 

와우 싱기하죠??

저는, NSCollectionLayoutSection을 학습하기 위해, Search 화면을 구현하기로 마음먹었습니다.

학습 및 구현에 시행착오가 있었는데, NSCollectionLayoutSection에 대해선, 나중에 포스팅 해볼게요!!!!다음처럼 2가지의 section 구현이 필요해요.나~~중에 알아보고 기본 구현 코드부터 볼게요.

 

12라인 : 데이터를 저장할 저장프로퍼티입니다. section2개로 구현이 필요하여, category와 discover 타입 2개를 가지고 있습니다.

사실 이렇게 구현하지 않아도 돼요. 따로따로 선언해줘도 무방합니다. 따로따로 선언하여 구현이 오히려 쉽습니다.

하지만, 섹션을 동적으로 받을 수 있도록 구현해봤어요. 동적으로 받지 않을거지만, 그냥 해봤어요

 

17라인 : discover section은 스크롤 시 다음 페이지를 가져와야 합니다. 이를 위한 저장 프로퍼티입니다.

 

18라인 : discover에 대한 중복 fetch를 방지하기 위한 저장 프로퍼티입니다. network 통신때 사용할거에요.

 

21라인 : searchBar의 scopeBar를 위한 프로퍼티입니다. 값이 변경되면, scopeBar의 isHidden을 설정합니다.

 

다음은 searchController 입니다.

28라인 : searchBar에서의 검색 결과에 대한 viewController입니다. 검색 결과를 가져와 보여주는 VC입니다. 이는 다음기회에~ 설명하게요.

 

35라인 : searchBar에 대한 검색결과 VC를 UISearchController에 지정합니다.

 

36라인 : 검색시 화면에 dim을 추가하는데 이를 false로 지정하여, dim을 추가하지 않도록 했습니다. 크게 중요하진 않아요.

 

37라인 : navigationBar hide를 방지 합니다. 이를 지정하지 않으면 nanvigationBar가 위로 슝~ 하고 날아가 버립니다.

38라인 : 현재 ViewController위에 다른 ViewController를 보여줄것인지를 설정합니다.

이해 잘 안되시죠? 이에 대해서 추후에 자세히 포스팅 해볼게요.

 

https://developer.apple.com/documentation/uikit/uiviewcontroller/1621456-definespresentationcontext/

 

Apple Developer Documentation

 

developer.apple.com

구글 번역느님의 말씀을 보자면, 다음과 같아요

 

UIModalPresentationStyle.currentContext 또는 UIModalPresentationStyle.overCurrentContext 스타일을 사용하여 뷰 컨트롤러를 표시하는 경우 이 속성은 뷰 컨트롤러 계층 구조의 기존 뷰 컨트롤러가 실제로 새 콘텐츠에 포함되는지 제어합니다. 컨텍스트 기반 프리젠테이션이 발생하면 UIKit은 프리젠테이션 뷰 컨트롤러에서 시작하여 뷰 컨트롤러 계층 구조를 따라 올라갑니다. 이 속성 값이 true인 뷰 컨트롤러를 찾으면 해당 뷰 컨트롤러에 새 뷰 컨트롤러를 표시하도록 요청합니다. 뷰 컨트롤러가 프레젠테이션 컨텍스트를 정의하지 않으면 UIKit은 프레젠테이션을 처리하도록 창의 루트 뷰 컨트롤러에 요청합니다. 이 속성의 기본값은 false입니다. UINavigationController와 같은 일부 시스템 제공 뷰 컨트롤러는 기본값을 true로 변경합니다.

 

읽어보면 잘 이해가 안되긴 하는데 definesPresentationContext 값이 true인 경우, 해당 VC 위에 VC를 보여줍니다.

결국 검색결과화면에서도 searchBar를 유지하기 위해서 "definesPresentationContext = false"를 사용합니다.

 

"definesPresentationContext = false인 화면

 

definesPresentationContext = true인 화면

40~45라인 : searchBar 세팅이죠.

 

47~56라인 : scopeButton 세팅이에요

 

89라인 : 그리고 navigationBar에 searchBar를 추가했습니다. title도 필요없기 때문에 titleView를 searchBar로 할당했어요.

 

다음은 collectionView입니다.

62라인 : layout을 UICollectionViewLayout로 구현했어요. 이는 아래에 작성할게요.

 

68~76라인 : cell, header 추가 했어요.

 

그리고, collectionView의 제약사항을 Snapkit으로 추가했어요

 

다음은 collectionView에 대한 DataSource와 Delegate입니다.

181라인 : section의 개수에요.

 

185라인 : section별 item 카운트 입니다.

 

다음은 section에 대한 cell, header 설정입니다

 

NSCollectionLayoutSection에서 prefetch를 적용하는데에 있어서, 추가적으로 계산이 필요하더라구요.

그래서, prefetch 말고, willDisplay를 통해 구현했습니다.

 

다음은, 네트워크 통신 로직 입니다.

이미, PhotoListViewController에서 살펴봤으니, 자세한 설명은 생략할게요.

 

searchBarDelegate입니다.

348라인 : 수정을 시작하면 cancel 버튼을 show 합니다.

 

352라인 : 검색 시, text가 비어있는지 확인하고, scopebar를 보여주고 검색을 시작합니다.

 

363라인 : 취소 버튼 터치 시, cancel 버튼을 숨기고, scopebar 를 숨깁니다. 또, 다음번의 검색을 위해서 결과를 리셋합니다.

 

372라인 : scope 버튼이 변경되면, 검색을 실행합니다.

 

 

여러분 여태까지, 기본적인 코드, 로직을 살펴봤어요.

이제  드디어 UICollectionViewLayout을 살펴봅시다!!!!!!!! ㅠㅜ

갑자기  글 작성하는데, 머리가 아프네요;;;;;;;;

자세한 설명은 추후 포스팅으로 올릴게요. 오늘은 코드에 대한 설명을 할게요. 그래도 이해는 충분히 될거에요.

시작에 앞서 애플 문서의 이미지를 잠깐 보시죠.

아이템은 무엇이고 그룹은 무엇이고 섹션은 무엇인지 느낌 오시죠???

 

section에 따라, NSCollectionLayoutSection을 리턴합니다. 아주 쉽죠????

 

categorySection에 대한 레이아웃이에요.

123~127라인 : 아이템에 대한 설정을 했어요.

 

129~132라인 : 아이템을 기반으로 그룹에 대한 설정을 했어요.

 

134~137라인 : 그룹을 기반으로 섹션에 대한 설정을 했어요. 136라인의 코드를 통해, 스크롤시 그룹별로 페이징을 수행합니다.

 

139~140라인 : "Browse by Category"라는 헤더 설정이에요.

 

 

다음은 Discover section입니다. 역시 동일해요, 아이템, 그룹, 섹션, 헤더~

아쉽게도, 코드 수정이 필요합니다. waterpull 처럼, 다양한 height로 구현해야 하는데, 우선 메인 로직부터 구현하고 있어요.

inset 설정이 은근 빡센데, 잘 계산해야 해요.

 

다음은 헤더입니다.

헤더도 간단하게 구현해줍니다.

 

머리가 계속 아파서 그런지

디테일하게 설명하고 싶었는데, 작성하다보니, 이해는 얼추 할것 같아 작성하지 않았어요.

 

이렇게 오랫동안 글 작성한적이 없는데;;;

그럼 오늘은 이만 작성할게요 수고하셨어요.

 

 

 

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

 

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 SearchNavigationViewController: UINavigationController {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        //navigationBar apeearance 설정
        let appearance = UINavigationBarAppearance()
        appearance.backgroundColor = .black
        appearance.titleTextAttributes = [.foregroundColor: UIColor.white]
        
        navigationBar.standardAppearance = appearance
        navigationBar.compactAppearance = appearance
        navigationBar.scrollEdgeAppearance = appearance
    }
    
    override var preferredStatusBarStyle: UIStatusBarStyle {
        return .lightContent
    }
}

 

import UIKit
import SnapKit

class SearchMainViewController: UIViewController {
    private var contents: [SearchMainItem] = [
        SearchMainItem(type: .category, items: []),
        SearchMainItem(type: .discover, items: [])
    ]
    //Discover 페이징을 위한 저장 프로퍼티
    private var discoverPageNum = 0
    private var isFetching = false
    
    //scopeBar show 관련 프로퍼티
    private lazy var isShowScopeBar = false {
        willSet {
            searchController.searchBar.showsScopeBar = newValue
        }
    }
    
    //검색 결과 표시를 위한 VC
    private lazy var searchResultVC: SearchResultViewController = {
        return SearchResultViewController()
    }()
    
    //searchController
    private lazy var searchController: UISearchController = {
        //searchController 설정
        let searchController = UISearchController(searchResultsController: searchResultVC)
        searchController.dimsBackgroundDuringPresentation = false
        searchController.hidesNavigationBarDuringPresentation = false
        definesPresentationContext = true
        
        //searchBar 설정
        searchController.searchBar.delegate = self
        searchController.searchBar.placeholder = "Search photos, collections, users"
        searchController.searchBar.tintColor = .white
        //SearchBar 입력 Text Color 변경
        UITextField.appearance(whenContainedInInstancesOf: [UISearchBar.self]).defaultTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white]
        
        //ScopeBar Button 설정
        searchController.searchBar.showsScopeBar = false
        searchController.searchBar.scopeButtonTitles = ["Photos", "Collections", "Users"]
        
        //scopebar 버튼 color 변경
        let selectedTitleTextColor = [NSAttributedString.Key.foregroundColor: UIColor.black]
        UISegmentedControl.appearance().setTitleTextAttributes(selectedTitleTextColor, for: .selected)
        
        let normalTitleTextColor = [NSAttributedString.Key.foregroundColor: UIColor.white]
        UISegmentedControl.appearance().setTitleTextAttributes(normalTitleTextColor, for: .normal)
        return searchController
    }()
    
    //collectionView 설정
    private lazy var collectionView: UICollectionView = {
        var collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout())
        collectionView.backgroundColor = .black
        collectionView.delegate = self
        collectionView.dataSource = self
        
        //Header 추가
        collectionView.register(
            SearchMainCollectionViewHeader.self,
            forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
            withReuseIdentifier: "SearchMainCollectionViewHeader"
        )
        
        //cell 추가
        collectionView.register(SearchCategoryCollectionViewCell.self, forCellWithReuseIdentifier: "SearchCategoryCollectionViewCell")
        collectionView.register(SearchDiscoverCollectionViewCell.self, forCellWithReuseIdentifier: "SearchDiscoverCollectionViewCell")
        return collectionView
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        setupNavigationBar()
        setupLayout()
        fetchCategories()
        fetchDiscover()
    }
    
    private func setupNavigationBar() {
        navigationController?.navigationItem.searchController = searchController
        navigationItem.titleView = searchController.searchBar
    }
    
    private func setupLayout() {
        view.backgroundColor = .black
        view.addSubview(collectionView)
        
        collectionView.snp.makeConstraints {
            $0.top.equalTo(view.safeAreaLayoutGuide)
            $0.leading.trailing.bottom.equalToSuperview()
        }
    }
}

extension SearchMainViewController {
    private func collectionViewLayout() -> UICollectionViewLayout {
        return UICollectionViewCompositionalLayout { [weak self] section, _ -> NSCollectionLayoutSection? in
            switch self?.contents[section].type {
            case .category:
                return self?.createCategorySection()
                
            case .discover:
                return self?.createDiscoverSection()
                
            default:
                return nil
            }
        }
    }
    
    //category section
    private func createCategorySection() -> NSCollectionLayoutSection {
        //아이템
        let itemMargin: CGFloat = 5
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(1))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        item.contentInsets = .init(top: itemMargin, leading: itemMargin, bottom: itemMargin, trailing: itemMargin)
        
        //그룹
        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.3), heightDimension: .fractionalWidth(0.6))
        let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitem: item, count: 2)
        group.contentInsets = .init(top: 0, leading: itemMargin, bottom: 0, trailing: itemMargin)
        
        //섹션
        let section = NSCollectionLayoutSection(group: group)
        section.orthogonalScrollingBehavior = .groupPaging
//        section.contentInsets = .init(top: 0, leading: 0, bottom: 0, trailing: 0)
        
        let sectionHeader = createDiscoverSectionHeader()
        section.boundarySupplementaryItems = [sectionHeader]
        return section
    }
    
    //discover section
    //TODO: discover section을 waterfull 방식으로 변경 필요
    private func createDiscoverSection() -> NSCollectionLayoutSection {
        //아이템
        let itemMargin: CGFloat = 1
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        item.contentInsets = .init(top: itemMargin, leading: itemMargin, bottom: itemMargin, trailing: itemMargin)
        
        //그룹
        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(200))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 2)
//        group.contentInsets = .init(top: 0, leading: 0, bottom: 0, trailing: 0)
        
        //섹션
        let section = NSCollectionLayoutSection(group: group)
        section.orthogonalScrollingBehavior = .none
//        section.contentInsets = .init(top: 0, leading: 0, bottom: 0, trailing: 0)
        
        let sectionHeader = createDiscoverSectionHeader()
        section.boundarySupplementaryItems = [sectionHeader]
        return section
    }
    
    private func createDiscoverSectionHeader() -> NSCollectionLayoutBoundarySupplementaryItem {
        //Header Header size
        let layoutSectionHeaderSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(60))
        
        //section header layout
        let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(
            layoutSize: layoutSectionHeaderSize,
            elementKind: UICollectionView.elementKindSectionHeader,
            alignment: .top
        )
        
        return sectionHeader
    }
}

extension SearchMainViewController: UICollectionViewDataSource, UICollectionViewDelegate {
    
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return contents.count
    }
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return contents[section].items.count
    }
    
    //cell 설정
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        
        switch contents[indexPath.section].type {
        case .category:
            guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "SearchCategoryCollectionViewCell", for: indexPath) as? SearchCategoryCollectionViewCell else {
                return UICollectionViewCell()
            }
            
            guard let categories = contents[indexPath.section].items as? [Category] else {
                return UICollectionViewCell()
            }
            let category = categories[indexPath.row]
            cell.setup(category: category)
            return cell
            
        case .discover:
            guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "SearchDiscoverCollectionViewCell", for: indexPath) as? SearchDiscoverCollectionViewCell else {
                return UICollectionViewCell()
            }
            
            guard let photos = contents[indexPath.section].items as? [Photo] else {
                return UICollectionViewCell()
            }
            let photo = photos[indexPath.row]
            cell.setup(photo: photo)
            return cell
        }
    }
    
    //header 설정
    func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
        if kind == UICollectionView.elementKindSectionHeader {
            guard let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "SearchMainCollectionViewHeader", for: indexPath) as? SearchMainCollectionViewHeader else {
                return UICollectionReusableView()
            }
            
            switch contents[indexPath.section].type {
            case .category:
                headerView.setTitle(title: "Browse by Category")
                
            case .discover:
                headerView.setTitle(title: "Discover")
            }
            return headerView
        }
        return UICollectionReusableView()
    }

    //willDisplay로 prefetch 적용
    func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
        if indexPath.section != 1 {
            return
        }

        let row = indexPath.row
        if row == contents[1].items.count - 11 ||
            row == contents[1].items.count - 6 ||
            row == contents[1].items.count - 1 {
            fetchDiscover()
        }
    }
}

//MARK: fetch
extension SearchMainViewController {
    
    //fetch catrgories
    private func fetchCategories() {
        UnsplashAPI.fetchCategories { [weak self] data, response, error in
            guard error == nil,
                  let response = response as? HTTPURLResponse,
                  let data = data else {
                      DispatchQueue.main.async {    //에러 발생 시 에러 보여주기
                          self?.showNetworkErrorAlert(error: .networkError)
                      }
                      return
                  }
            
            switch response.statusCode {
            //response 성공 시, 목록 설정하기
            case (200...299):
                do {
                    let fetchedCategories = try JSONDecoder().decode([Category].self, from: data)
                    self?.contents[0].items = fetchedCategories
                    
                    DispatchQueue.main.async {
                        self?.collectionView.reloadData()
                    }
                } catch {
                    DispatchQueue.main.async {  //에러 발생 시 에러 보여주기
                        self?.showNetworkErrorAlert(error: .jsonParsingError)
                    }
                }
                
            default:
                DispatchQueue.main.async {  //에러 발생 시 에러 보여주기
                    self?.showNetworkErrorAlert(error: .networkError)
                }
                return
            }
        }
    }
    
    //fetch discover
    private func fetchDiscover() {
        if isFetching {
            return
        }
        isFetching = true
        
        UnsplashAPI.fetchPhotos(pageNum: discoverPageNum + 1) { [weak self] data, response, error in
            self?.isFetching = false
            guard error == nil,
                  let response = response as? HTTPURLResponse,
                  let data = data else {
                      DispatchQueue.main.async {    //에러 발생 시 에러 보여주기
                          self?.showNetworkErrorAlert(error: .networkError)
                      }
                      return
                  }
            
            switch response.statusCode {
            //response 성공 시, 목록 설정하기
            case (200...299):
                do {
                    let fetchedPhotos = try JSONDecoder().decode([Photo].self, from: data)
                    
                    if self?.discoverPageNum == 0 { //첫페이지를 가져온 경우 목록 설정
                        self?.contents[1].items = fetchedPhotos
                    } else { //첫페이지 외 다음페이지를 가져온 경우 목록 설정
                        self?.contents[1].items.append(contentsOf: fetchedPhotos)
                    }
                    
                    DispatchQueue.main.async {
                        //다음 페이지 번호 설정
                        self?.discoverPageNum += 1
                        self?.collectionView.reloadData()
                    }
                } catch {
                    DispatchQueue.main.async {  //에러 발생 시 에러 보여주기
                        self?.showNetworkErrorAlert(error: .jsonParsingError)
                    }
                }
                
            default:
                DispatchQueue.main.async {  //에러 발생 시 에러 보여주기
                    self?.showNetworkErrorAlert(error: .networkError)
                }
                return
            }
        }
    }
}

//MARK: UISearchBarDelegate
extension SearchMainViewController: UISearchBarDelegate {
    
    //searchBar 수정 시작 시 cancelButton show
    func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
        searchBar.setShowsCancelButton(true, animated: true)
    }
    
    //search 버튼 터치 시
    func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
        if let searchBarText = searchBar.text, !searchBarText.isEmpty {
            isShowScopeBar = true
            
            view.endEditing(true)
            //search
            searchResultVC.fetchFirstSearch(searchText: searchBarText)
        }
    }
    
    //cancel 버튼 터치 시
    func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
        searchBar.setShowsCancelButton(false, animated: true)
        isShowScopeBar = false
        
        searchResultVC.resetResult()
    }
    
    //선택한 ScopeButton이 변경되면, 검색 수행
    func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
        var selectType: SearchType = .Photos
        switch selectedScope {
        case 1:
            selectType = .Collections
        case 2:
            selectType = .Users
        default:
            selectType = .Photos
        }
        
        //search
        searchResultVC.currentSearchType = selectType
    }
}
반응형