본문 바로가기

iOS 개발/Apple App Store 클론 코딩

AppleAppStore - 10. Search Result 화면 구현

반응형

안녕하세요~~~~~

1월 3일 입니다. 작심삼일이란 말이 있는데, 어떠신가요???

 

두려웠던 부분이 Search Result VC가 아니라, AppDetailVC였네요.

오늘까지는 수월해 보입니다. 으흐흐흐흐

 

그럼 바로 가봅시다.

------------------------------------------------------------------------------------------------------------------

 

12라인 : 검색목록에서 앱 선택 시 화면 전환을 지난 글에 구현했던 SearchViewController에 위임합니다. SearchResultViewController에서 navigationController?.pushViewController(vc, animated: true)수행 시, 정상 동작을 하지 않아, 화면 전환을 위임합니다.

 

13라인 : 검색 목록입니다. 목록이 변경될 때 마다, collectionView를 reload하고, 적절한 view를 보여줍니다.

 

25라인 : 검색 결과가 0건인 경우 보여주는 view입니다. SearchViewController에서 구현한 empty view와 동일합니다.

UIView에 UILabel을 추가하고 snapkit으로 제약사항을 구성했습니다.

 

44라인 : 검색 목록을 보여줄 collectionView입니다. 사용할 cell을 등록했습니다.

 

48라인 : 드래그 시 키보드를 dismiss하도록 추가했습니다.

 

53라인 : viewDidLoad에서 네비게이션바와 레이아웃을 구성합니다.

 

59라인 : collectionView와 emptyView를 추가하고 제약사항을 구성해줍니다.

여러분! 이제는 SnapKit 사용이 자유롭죠???? 호호호호호호 

 

77라인 : collectionViewDataSource입니다. extension으로 구현했어요.

extension으로 코드를 나눠 구현하여 결과적으로 코드 가독성을 높일 수 있습니다만 다들 아시죠?

 

82라인 : 검색 결과에 대한 cell을 구성하여 return 합니다.

 

93라인 : delegate입니다. 역시 extension으로 구현했습니다.

 

95라인 : didSelectItemAt에서 아이템 터치에 대한 동작을 delegate에 위임했습니다. 위임한 이유는 네비게이션바 때문입니다.

AppStore 앱을 실행하고 검색 탭에가서 앱 검색해보세요. 그리고 네비게이션바에 집중하면서 결과 목록에서 앱 하나 터치하여 상세화면으로 이동해보고, 다시 결과 목록으로 돌아가 보세요. 

이처럼 navigationController을 사용하는게 눈에 보이죠?????

그치만 SearchResultViewController은 navigationController을 사용하지 않습니다.

그래서, navigationController을 활용하는 SearchViewController에 화면 전환을 위임하였습니다.

혹시 다른 좋은 방법이 있다면 알려주세요!!!!!!

 

100, 105라인 : cell 사이즈를 설정했어요.

 

110라인 : 검색을 구현했습니다.

 

112라인 : 검색 전에, 이전 검색 결과를 리셋합니다.

 

114라인 : 검색을 수행합니다. 우선 error처리는 따로 하지 않았어요. error처리는 Unsplash 클론코딩과 같이 적절하게(alert으로) 처리하면 됩니다. 검색 결과만 보여주도록 할게요!!

물론 앱 개발에서의 에러 처리는 아주아주 매우매우 박박 중요합니다.

 

124라인 : json에서 검색 목록을 가져와 검색 결과인 item 목록을 갱신합니다. 목록이 변경되면 didSet에서 collectionView를 reload하기에, 따로 구현하지 않았습니다.

 

------------------------------------------------------------------------------------------------------------------

 

그럼 이번엔 cell을 보시죠

다음 스샷은 제가 구현한 cell인데요, snapKit을 활용하여 cell을 구현할거에요.

AppStore의 레이아웃과 비율, 사이즈가 다를수 있습니다. 이는 중요하지만 중요하지 않다 생각합니다?????

현업에선 디자인 가이드가 존재합니다. 특정 레이아웃의 사이즈나 비율 등 상세 수치가 명시된 가이드입니다.

개발자를 디자인 가이드를 준수하며 개발을 수행합니다.

그런데 지금은 디자인 가이드가 없죠?? 시간만 들이면, 레이아웃을 최대한 유사하게 구현할 수 있습니다.

하지만 제가 중점을 두는것은 구현 로직들이지, 정확한 레이아웃이 아닙니다.

따라서 중요하지만 중요하지 않습니다????

물론 레이아웃을 완벽하게 구현해주면 아주아주 최고입니다.

 

먼저 레이아웃 구성을 볼게요



이렇게 레이아웃 배치를 구성했습니다.

편한대로 혹은 더 좋은 방향으로 레이아웃을 구성해주면 돼요. 

 

11라인 : 앱 아이콘을 보여주는 view입니다. imageView를 상속한 커스텀 DownloadableImageView를 사용했습니다.

 

19라인 : 앱 이름 입니다.  최대 2줄까지 보여줄 수 있어요

 

27라인 : 앱 간략 설명입니다.

 

34라인 : 리뷰 카운트를 보여주는 라벨입니다.

 

41라인 : 라벨들을 스택뷰로 구현합니다.

 

57, 67, 77라인 : 스크린샷View입니다. 역시 DownloadableImageView로 구현했어요.

 

87라인 : 스크린샷 View를 스택뷰로 묶어버렸습니다.

 

102라인 : 버튼 입니다.

 

111라인 : 인앱 구매 표시 라벨입니다.

 

119라인 : 초기화시 레이아웃을 구성합니다.

 

128라인 : 레이아웃을 추가하고, 제약사항을 구성합니다. snapKit에 익숙하지 않다면, 씹고 뜯고 맛보고 즐겨보세요.

 

172라인 : 검색 앱에 대한 설정입니다.text와 이미지들을 설정합니다. 181라인은 이미지 url을 통해 이미지를 가져오는 커스텀 함수를 호출하였습니다. url을 통해 이미지 설정했다고 생각하시면 됩니다.

 

------------------------------------------------------------------------------------------------------------------

 

다음은 API입니다.

 

아이튠즈에서 최소한의 api를 제공합니다.

특히 클론코딩에 아주아주아주앚우자웆아주아주우ㅏ아주 중요한 search API를 제공합니다!!

애플느님 매우 감사합니다.

다음은 아이튠즈 api 문서에요. 한번 쭈~~~~~욱 읽어보세요.

https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/Searching.html#//apple_ref/doc/uid/TP40017632-CH5-SW1

 

iTunes Search API: Constructing Searches

 

developer.apple.com

 

코드 바로 볼게요.

13라인 : urlComponents입니다. scheme과 host등 설정했습니다.

 

21라인 : path를 설정하고, urlSessionDataTask를 수행합니다.

 

설명이 너무 간단하나여;;;;;;;;; 너무 간단해서 간단한겁니다!

------------------------------------------------------------------------------------------------------------------

 

이렇게 검색 목록 화면을 구현해봤어요. 어떠신가요??????

복잡하다 생각했으나 생각보다 간단했고, 간단하다 생각했으나 생각보다 간단하지 않았네요.

 

흥미로운 이슈가 있는데요. 2022년이 되고, Y2K22 버그가 발생했다네요.

Y2K 밀레니엄 버그가 뭐죠???? 전 절대 모릅니다.

https://www.inews24.com/view/1438744

 

“새해 메일이 막혔어요”…MS 익스체인지 ‘Y2K22 버그

[아이뉴스24 김문기 기자] 전세계 마이크로소프트 익스체인지 관리자들이 지난 1월 1일 자정부터 온프레미스 서버의 이메일 전달이 차단됐다고 전했다. 전세계 마이크로소프트 익스체인

www.inews24.com

 

 

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

 

Skillist의 AppleAppStore 프로젝트

https://github.com/DeveloperSkillist/AppleAppStoreCloneCode

 

GitHub - DeveloperSkillist/AppleAppStoreCloneCode: AppleAppStoreCloneCode

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

github.com

 

 

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

import UIKit

class SearchResultViewController: UIViewController {
    
    weak var detailAppDelegate: DetailAppVCDelegate?
    var items: [SearchItemResult] = [] {
        didSet {
            DispatchQueue.main.async {
                self.collectionView.reloadData()
                
                let isEmpty = self.items.count == 0
                self.collectionView.isHidden = isEmpty
                self.emptyView.isHidden = !isEmpty
            }
        }
    }
    
    private lazy var emptyView: UIView = {
        var emptyLabel = UILabel()
        emptyLabel.textColor = .label
        emptyLabel.text = "search_empty".localized
        emptyLabel.font = .systemFont(ofSize: 20)
        emptyLabel.numberOfLines = 0
        
        var emptyView = UIView()
        emptyView.backgroundColor = .systemBackground
        emptyView.addSubview(emptyLabel)
        emptyView.isHidden = true
        
        emptyLabel.snp.makeConstraints {
            $0.center.equalToSuperview().inset(20)
        }
        
        return emptyView
    }()
    
    private lazy var collectionView: UICollectionView = {
        var collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout())
        collectionView.dataSource = self
        collectionView.delegate = self
        collectionView.keyboardDismissMode = .onDrag
        collectionView.register(SearchResultAppCollectionViewCell.self, forCellWithReuseIdentifier: "SearchResultAppCollectionViewCell")
        return collectionView
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupLayout()
    }
    
    private func setupLayout() {
        [
            collectionView,
            emptyView
        ].forEach {
            view.addSubview($0)
        }
        
        collectionView.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
        
        emptyView.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
    }
}

extension SearchResultViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return items.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "SearchResultAppCollectionViewCell", for: indexPath) as? SearchResultAppCollectionViewCell else {
            return UICollectionViewCell()
        }
        let item = items[indexPath.row]
        cell.setupItem(item: item)
        return cell
    }
}

extension SearchResultViewController: UICollectionViewDelegateFlowLayout {
    
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        let item = items[indexPath.row]
        detailAppDelegate?.pushDetailVC(item: item)
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        
        return CGSize(width: collectionView.frame.width - 32, height: 330)
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
        return 50
    }
}

extension SearchResultViewController {
    func searchItems(searchText: String) {
        resetResult()
        
        ItunesAPI.searchApps(term: searchText) { [weak self] data, response, error in
            guard error == nil,
                  let response = response as? HTTPURLResponse,
                  let data = data else {
                      //TODO: error
                      return
                  }
            switch response.statusCode {
            case (200...299):
                do {
                    let searchedItems = try JSONDecoder().decode(SearchResult.self, from: data)
                    
                    print("searchedItems : \(searchedItems)")
                    self?.items = searchedItems.results
                } catch {
                    print("error: \(error)")
                }
                
            default:
                //TODO: error
                return
            }
        }
    }
    
    private func resetResult() {
        items = []
        
        collectionView.isHidden = true
        emptyView.isHidden = true
    }
}
import UIKit

class SearchResultAppCollectionViewCell: UICollectionViewCell {
    private lazy var iconView: DownloadableImageView = {
        var imageView = DownloadableImageView()
        imageView.contentMode = .scaleToFill
        imageView.layer.cornerRadius = 10
        imageView.clipsToBounds = true
        return imageView
    }()
    
    private lazy var mainText: UILabel = {
        var label = UILabel()
        label.textColor = .label
        label.font = .systemFont(ofSize: 18, weight: .bold)
        label.numberOfLines = 2
        return label
    }()
    
    private lazy var subText: UILabel = {
        var label = UILabel()
        label.textColor = .darkGray
        label.font = .systemFont(ofSize: 15, weight: .semibold)
        return label
    }()
    
    private lazy var reviewText: UILabel = {
        var label = UILabel()
        label.textColor = .darkGray
        label.font = .systemFont(ofSize: 15, weight: .semibold)
        return label
    }()
    
    private lazy var textStackView: UIStackView = {
        var stackView = UIStackView()
        stackView.axis = .vertical
        stackView.distribution = .equalSpacing
        stackView.spacing = 5
        
        [
            mainText,
            subText,
            reviewText
        ].forEach {
            stackView.addArrangedSubview($0)
        }
        return stackView
    }()
    
    private lazy var screenShotImageView1: DownloadableImageView = {
        var imageView = DownloadableImageView()
        imageView.contentMode = .scaleToFill
        imageView.layer.cornerRadius = 10
        imageView.clipsToBounds = true
        imageView.layer.borderWidth = 1
        imageView.layer.borderColor = UIColor.lightGray.cgColor
        return imageView
    }()
    
    private lazy var screenShotImageView2: DownloadableImageView = {
        var imageView = DownloadableImageView()
        imageView.contentMode = .scaleToFill
        imageView.layer.cornerRadius = 10
        imageView.clipsToBounds = true
        imageView.layer.borderWidth = 1
        imageView.layer.borderColor = UIColor.lightGray.cgColor
        return imageView
    }()
    
    private lazy var screenShotImageView3: DownloadableImageView = {
        var imageView = DownloadableImageView()
        imageView.contentMode = .scaleToFill
        imageView.layer.cornerRadius = 10
        imageView.clipsToBounds = true
        imageView.layer.borderWidth = 1
        imageView.layer.borderColor = UIColor.lightGray.cgColor
        return imageView
    }()
    
    private lazy var screenShotStackView: UIStackView = {
        var stackView = UIStackView()
        stackView.axis = .horizontal
        stackView.distribution = .fillEqually
        stackView.spacing = 5
        [
            screenShotImageView1,
            screenShotImageView2,
            screenShotImageView3
        ].forEach {
            stackView.addArrangedSubview($0)
        }
        return stackView
    }()
    
    private lazy var appActionButton: UIButton = {
        var button = UIButton()
        button.setTitle("down_title".localized, for: .normal)
        button.setTitleColor(.link, for: .normal)
        button.backgroundColor = UIColor(named: "lightgray,darkgray")
        button.layer.cornerRadius = 15
        return button
    }()
    
    private lazy var inAppPurchaseText: UILabel = {
        var label = UILabel()
        label.textColor = .darkGray
        label.font = .systemFont(ofSize: 13)
        label.textAlignment = .center
        return label
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupLayout()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupLayout() {
        [
            iconView,
            textStackView,
            appActionButton,
            inAppPurchaseText,
            screenShotStackView
        ].forEach {
            addSubview($0)
        }
        
        iconView.snp.makeConstraints {
            $0.top.leading.equalToSuperview()
            $0.width.height.equalTo(70)
        }
        
        textStackView.snp.makeConstraints {
            $0.top.bottom.equalTo(iconView)
            $0.leading.equalTo(iconView.snp.trailing).offset(10)
            $0.trailing.equalTo(appActionButton.snp.leading).offset(-10)
        }
        
        appActionButton.snp.makeConstraints {
            $0.centerY.equalTo(iconView)
            $0.trailing.equalToSuperview()
            $0.width.equalTo(70)
        }
        
        inAppPurchaseText.snp.makeConstraints {
            $0.top.equalTo(appActionButton.snp.bottom).offset(5)
            $0.bottom.equalTo(iconView)
            $0.leading.trailing.equalTo(appActionButton)
            $0.centerX.equalTo(appActionButton)
        }

        screenShotStackView.snp.makeConstraints {
            $0.top.equalTo(iconView.snp.bottom).offset(10)
            $0.leading.equalTo(iconView)
            $0.trailing.equalTo(appActionButton)
            $0.bottom.equalToSuperview()
//            $0.height.equalTo(300)
        }
    }
    
    func setupItem(item: SearchItemResult) {
        mainText.text = item.trackName
        subText.text = item.resultDescription
        reviewText.text = String(item.userRatingCount)
        inAppPurchaseText.text = "in_app_purchase".localized
        
        iconView.downloadImage(url: item.artworkUrl512)
        
        let screenShotUrls = item.screenshotUrls
        if screenShotUrls.count > 0 {
            screenShotImageView1.downloadImage(url: screenShotUrls[0])
            print("screenshot: \(screenShotUrls[0])")
            screenShotImageView2.downloadImage(url: screenShotUrls[1])
            screenShotImageView3.downloadImage(url: screenShotUrls[2])
        }
    }
}
import Foundation

class ItunesAPI {
    static var dataTask: URLSessionDataTask?
    
    static var itunesURLComponents: URLComponents {
        var urlComponent = URLComponents()
        urlComponent.scheme = "https"
        urlComponent.host = "itunes.apple.com"
        return urlComponent
    }
    
    //https://itunes.apple.com/search?entity=software&term=
    static func searchApps(term: String, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) {
        var urlComponents = itunesURLComponents
        urlComponents.path = "/search"
        urlComponents.queryItems = [
            URLQueryItem(name: "entity", value: "software"),
            URLQueryItem(name: "term", value: term)
        ]
        
        guard let url = urlComponents.url else {
            return
        }
        print("url : \(url.description)")
        
        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        
        dataTask = URLSession.shared.dataTask(with: url, completionHandler: completionHandler)
        dataTask?.resume()
    }
}
반응형