[README] WishList

승아·2021년 3월 10일
0
post-thumbnail

WishList ✨

  • 나만의 위시리스트를 관리할 수 있는 앱
  • github

기능

✅ Wish 추가

  • 사진, 태그, 이름, 메모, 링크, 장소로 이루어진 위시 추가 기능
  • PHPicker를 활용한 사진 추가
  • MapKit을 활용한 장소 추가

✅ Wish 수정, 삭제

✅ Wish 조회

  • 화살표 버튼 클릭 시 해당 링크 사파리로 연결

✅ Wish 검색

  • 태그와 이름을 기반으로 검색

✅ 즐겨찾는 Wish

✅ 공유 기능

  • 공유 시 해당 링크 Wish 추가

✅ 사진 편집

  • Mantis 라이브러리를 사용한 사진 편집 기능

✅ 현재 위치

  • 현재 위치 Wish 추가

✅ 장소 검색

  • 장소 검색 시 해당 위치 추가

✅ 지도 마커 표시

  • 장소가 추가된 Wish를 지도에 마커로 표시
  • 마커 클릭 시 해당 Wish로 이동

설계

✅ ViewController 구성

  • FavoriteWishViewController

  • MapViewController

  • SearchWishViewController

  • AddWishViewController

  • SelectWishViewController

✅ ViewCotroller의 역할

MainViewController

  • 나의 WishList를 보여준다.

FavoriteWishViewController

  • 관심 WishList를 보여준다.

MapViewController

  • 현재 위치를 보여준다.
  • 장소를 추가한 Wish의 위치를 마커로 보여준다.
  • 현재 위치와 검색한 위치를 Wish로 추가 할 수 있다.

SearchPlaceViewController

  • 위치를 검색할 수 있다.

SearchWishViewController

  • 검색어가 포함된 Wish를 이름, 태그 별로 검색할 수 있다.

AddWishViewController

  • 사진, 이름, 태그, 메모, 링크, 장소의 Wish를 추가할 수 있다.

EditImageViewController

  • 기기에서 선택한 사진을 편집할 수 있다.

SelectWishViewController

  • 선택한 Wish를 보여준다.

ShowImageViewController

  • 선택한 image를 원본 크기로 보여준다.

✅ ViewModel의 역할

WishViewModel

  • Wish의 데이터를 불러온다.
  • Wish 데이터를 관리한다.

FavoriteWishViewModel

  • FavoriteWish(관심 Wish)를 불러온다.

FavoriteWishViewModel

  • FilterWish(검색 Wish)를 불러온다.

구현

✅ Notification

Notification

등록된 노티피케이션에 노티피케이션 센터를 통해 정보를 전달하기 위한 구조체.

주요 프로퍼티

  • var name: Notification.Name // 알림을 식별하는 태그
  • var object: Any? // 발송자가 옵저버에게 보내려고 하는 객체. 주로 발송자 객체를 전달하는데 쓰임
  • var userInfo:[AnyHashable: Any]? // 노티피케이션과 관련된 값 또는 객체의 저장소

NotificationCenter

Notification을 발송하면 NotificationCenter에서 메세지를 전달한 옵저버를 처리할 때까지 대기합니다. 즉, 흐름이 동기적(synchronous)으로 흘러갑니다. 노티피케이션을 비동기적으로 사용하려면 NotificationQueue를 사용하면 된다.

NotificationCenter 구현 순서

1. 옵저버 등록

class SelectWishViewController: UIViewController {
  override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(true)
    // NotificationCenter에 옵저버 등록 
    NotificationCenter.default.addObserver(self, selector: #selector(convertToUIImageComplete(_ :)), name: NotificationName.ChangeImageNotification, object: nil)
  }

2. 발송

extension WishViewModel {
  func changeUIImage(index: Int){
    ChangeUIImage.changeUIImage(imageURL: manager.wishs[index].imgURL){ [weak self] image in
      self?.manager.wishs[index].img = image
      // NoticationCenter에 발송
      NotificationCenter.default.post(name: NotificationName.ChangeImageNotification, object: nil)
    }
  }

3. 호출

class SelectWishViewController: UIViewController {
  @objc func convertToUIImageComplete(_ noti: Notification){
    DispatchQueue.main.async {
      self.indicator.stopAnimating()
      self.presentAddWishListVC()
    }
  }

4. 옵저버 제거

  • Notification 중복 방지를 위해 NoticationCenter에서 옵저버를 제거 해줘야된다.
class SelectWishViewController: UIViewController {
  override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(true)
    // NotificationCenter에서 옵저버 제거
    NotificationCenter.default.removeObserver(self, name: NotificationName.ChangeImageNotification, object: nil)
  }

✅ PHPicker

https://zeddios.tistory.com/1052
https://velog.io/@sainkr/TIL-2021.02.02

  • UIImagePickerController에 추가로 multiselect / zoom in or out / search 를 지원함.
  • iOS 14 이상부터 쓸 수 있다.
  • 따로 권한 요청 팝업이 뜨지 않는다.

1. import PhotosUI

2. PHPickerConfiguration 생성

var configuration = PHPickerConfiguration()
configuration.selectionLimit = 5
configuration.filter = .any(of: [.images])
  • selectionLimit : 최대 asset 수를 지정하는 프로퍼티, 기본 값은 1 무한 대는 0을 의미한다.
  • filter: 이미지, 비디오, 라이브포토 필터 선택. nil이면 모두를 의미한다.

3. PHPickerConfiguration을 갖는 PHPickerViewController 생성

let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
picker.modalPresentationStyle = .fullScreen
self.present(picker, animated: true, completion: nil)

4. PHPickerViewControllerDelegate

extension AddImageViewController: PHPickerViewControllerDelegate {
  @available(iOS 14, *)
  func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
    picker.dismiss(animated: true)
    let loadUIImage = LoadUIImage(results.map(\.itemProvider))
    loadUIImage.image{ images in
      self.presentEditImageViewController(images)
    }
  }
}
  • itemProvider로 Array 값을 가져오고 iterator를 사용하여 데이터를 읽는다.
  • 데이터를 load할 수 있다면 UIImage Type으로 load 한다.
class LoadUIImage{
  private var itemProviders: [NSItemProvider] = []
  
  init(_ itemProvdiers: [NSItemProvider]){
    self.itemProviders = itemProvdiers
  }
  
  func image(completion: @escaping (([UIImage]) -> ())){
    var iterator: IndexingIterator<[NSItemProvider]> = itemProviders.makeIterator()
    var images: [UIImage] = []
    while true {
      guard let itemProvider = iterator.next(), itemProvider.canLoadObject(ofClass: UIImage.self) else { break }
      itemProvider.loadObject(ofClass: UIImage.self) { image, error in
        if let error = error {
          print("loadImageError : \(error)")
          return
        }
        guard let image = image as? UIImage else { return }
        images.append(image)
        if images.count == self.itemProviders.count{
          completion(images)
        }
      }
    }
  }
}

✅ Dynamic cell sizing

https://ntomios.tistory.com/15
https://velog.io/@sainkr/TIL-hb5ddnvb

UICollectionViewCell

class AddTagCollectionViewCell: UICollectionViewCell{
  static let identifier = "AddTagCollectionViewCell"
  
  private let titleLabel: UILabel = UILabel()
  
  static func fittingSize(availableHeight: CGFloat, tag: String) -> CGSize {
    let cell = AddTagCollectionViewCell()
    cell.configure(tag: tag)
    let targetSize = CGSize(
      width: UIView.layoutFittingCompressedSize.width,
      height: availableHeight)
      
    return cell.contentView.systemLayoutSizeFitting(
      targetSize,
      withHorizontalFittingPriority: .fittingSizeLevel,
      verticalFittingPriority: .required)
  }

  func configure(tag: String) {
    titleLabel.text = tag
  }
// ...
  • targetSize의 width는 UIView.layoutFittingCompressedSize.width를 활용하여 뷰를 가능한 작게만들어 주었고 height은 입력받은 값으로 고정해주었다.
  • systemLayoutSizeFitting 함수를 통해 Dynamic cell sizing을 구현하였다.

systemLayoutSizeFitting(_:withHorizontalFittingPriority:verticalFittingPriority:)

특정 뷰를 width, height의 우선순위에 따라 targetSize에 맞는 Size를 계산하여 반환해주는 함수

  • targetSize
    - layoutFittingCompressedSize : 뷰를 가능한 작게
    - layoutFittingExpandedSize : 뷰를 가능한 크게
  • horizontalFittingPriority (width 우선순위)
    - content size에 맞게 늘어나야 함
    - 우선순위가 낮은 fittingSizeLevel로 설정
  • verticalFittingPriority (height 우선순위)
    - targetSize의 height에 반드시 맞출 필요가 있으므로 가장 우선순위가 높은 required로 설정
  • 우선순위
    required > defaultHigh > defaultLow > fittingSizeLevel

UICollectionViewDelegateFlowLayout

// MARK: - UICollectionViewDelegateFlowLayout
extension AddTagViewController: UICollectionViewDelegateFlowLayout {
  func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
    return AddTagCollectionViewCell.fittingSize(availableHeight: 45, tag: wishViewModel.tag(index: wishViewModel.lastWishIndex, tagIndex: indexPath.item))
  }
}

✅ Mantis

https://velog.io/@sainkr/TIL-2021.03.06

  • Image Edit(Crop, Rotation 등) Library
  1. cropViewController 띄우기
let cropViewController = Mantis.cropViewController(image: 해당 이미지 (UIImage))
cropViewController.delegate = self
cropViewController.modalPresentationStyle = .fullScreen
self.present(cropViewController, animated: true, completion: nil)
  1. 완료 됐을 때
  • cropViewControllerDidCrop() : Done 버튼 눌렀을 때 실행
  • cropViewControllerDidCancel() : Cancel 버튼 눌렀을 때 실행
extension EditImageViewController: CropViewControllerDelegate{
    func cropViewControllerDidCrop(_ cropViewController: CropViewController, cropped: UIImage, transformation: Transformation) {
        images[currentPage] = cropped
        updatePageViewController()
        dismiss(animated: true, completion: nil)
    }
    
    func cropViewControllerDidCancel(_ cropViewController: CropViewController, original: UIImage) {
        dismiss(animated: true, completion: nil)
    }
}

✅ SnapKit

  • constraint을 좀 더 간편하게 사용할 수 있는 오픈 라이브러리

SnapKit을 사용하지 않았을 때

titleBackView.translatesAutoresizingMaskIntoConstraints = false

titleBackView.topAnchor.constraint(equalTo: contentView.topAnchor).isActive = true
titleBackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 5).isActive = true
titleBackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor).isActive = true

SnapKit을 사용했을 때

titleBackView.snp.makeConstraints{(make) in
  make.top.equalToSuperview()
  make.leading.equalToSuperview().inset(5)
  make.bottom.equalToSuperview()
}
  • titleBackView.translatesAutoresizingMaskIntoConstraints = false을 내부에서 해주기 때문에 적어주지 않아도 됨.

translatesAutoresizingMaskIntoConstraints = false 를 쓰는 이유

translatesAutoresizingMaskIntoConstraints = false를 해주지 않고 코드에서 제약을 걸어주면 translatesAutoresizingMaskIntoConstraints = true가 되어 AutorisizingMask를 통해 제약이 자동 생성 되면서 코드로 걸어준 제약과 충돌하기 때문에 translatesAutoresizingMaskIntoConstraints를 false로 설정해줘야 된다.

✅ Firebase

https://velog.io/@sainkr/TIL-2021.04.01

1. Firebase initialize

import Firebase
import FirebaseStorage

class AppDelegate: UIResponder, UIApplicationDelegate {
  
  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // Override point for customization after application launch.
    // Use Firebase library to configure APIs
    FirebaseApp.configure()

2. FIRDatabaseReference, Storage 가져오기

let db =  Database.database().reference().child("myWhisList")
let storage = Storage.storage()

3. 데이터 쓰기

  • setValue()
    - 지정된 참조에 데이터를 저장하고 해당 경로의 기존 데이터를 모두 바꾼다.
    - NSString, NSNumber, NSDictionary, NSArray와 같은 유형을 전달함.
db
.child(String(wish.timestamp))
.setValue([
            "timestamp" : wish.timestamp,
            "name": wish.name ,
            "tag" : wish.tag,
            "imgURL" : ["-"],
            "memo" : wish.memo,
            "link" : wish.link,
            "placeName" : wish.place?.name ?? "-",
            "placeLat": wish.place?.lat ?? 0,
            "placeLng": wish.place?.lng ?? 0,
            "favorite": wish.favorite])

4. 데이터 읽기

  • observeSingleEvent()
    - 관찰자를 사용하여 데이터 한 번 읽기
    - 한 번 로드된 후 자주 변경되지 않거나 능동적으로 수신 대기할 필요가 없는 데이터에 유용. 예를 들어 사용자의 프로필 같은 경우
self.db.observeSingleEvent(of:.value) { (snapshot) in
        guard let wishValue = snapshot.value as? [String:Any] else { return }
        // print("---> snapshot : \(wishValue.values)")
        do {
          // 1. DB에서 받아온 Foundation 값을 json 데이터로 만들어 준다.
          let data = try JSONSerialization.data(withJSONObject: Array(wishValue.values), options: [])
          let decoder = JSONDecoder()
          // 2. [WishDB]로 디코딩 해준다.
          let wish = try decoder.decode([WishDB].self, from: data)
        }catch {
          print("---> error : \(error)")
        }
      }

UIImage를 Storage에 저장 후 ImageURL 값 가져오기.

1. UIImage를 Jpeg 형식의 데이터로 변경

// compressionQuailty : 0.0 ~ 1.0 의 값을 가지며 JPEG 이미지의 품질을 나타냄. 0.0은 최대 압축 (lowest quailty) 1.0은 최소 압축 (best quailty)
 let data = image.jpegData(compressionQuality: 0.1)!

2. Storage에 파일 업로드

  • putData()
    - Storage에 파일을 업로드하는 가장 간단한 방법
    - name, size, contentType 등 메타데이터를 포함할 수 있다.
    - NSData 객체를 취하고 FIRStorageUploadTask를 반환하며, 이를 사용하여 업로드를 관리하고 업로드 상태를 모니터링할 수 있다.
let metaData = StorageMetadata()
metaData.contentType = "image/png" // contentType metaData 설정
let imageName = "\(wish.timestamp)\(i)"
storage.reference().child(imageName).putData(data, metadata: metaData) { (data, err) in
  if let error = err {
    print("--> error:\(error.localizedDescription)")
    return
  }
}
  • downloadURL() : Storage에 올린 데이터의 URL을 가져옴.
self.storage.reference().child(imageName).downloadURL { (url, err) in
  if let error = err {
    print("--> error:\(error.localizedDescription)")
    return
  }

  guard let url = url else {
    print("--> urlError")
    return
  }
  imageURL.append(url.absoluteString)
  if imageURL.count == wish.img.count {
    completion(imageURL)
  }
}

전체 코드

private func convertUIImagetoImageURL(wish: Wish, completion: @escaping (_ imageURL: [String]) -> Void){
  var imageURL: [String] = []
  for i in wish.img.indices{
    let image: UIImage = wish.img[i]
    // 지정된 이미지를 JPEG 형식으로 포함하는 데이터 개체를 반환합니다., JPEG 이미지의 품질 최대 압축 ~ 최소 압축
    let data = image.jpegData(compressionQuality: 0.1)!
    let metaData = StorageMetadata()
    metaData.contentType = "image/png"
    let imageName = "\(wish.timestamp)\(i)"
    storage.reference().child(imageName).putData(data, metadata: metaData) { (data, err) in
      if let error = err {
        print("--> error:\(error.localizedDescription)")
        return
      }
      self.storage.reference().child(imageName).downloadURL { (url, err) in
        if let error = err {
          print("--> error: \(error.localizedDescription)")
          return
        }
        guard let url = url else {
          print("--> urlError")
          return
        }
        imageURL.append(url.absoluteString)
        if imageURL.count == wish.img.count {
          completion(imageURL)
        }
      }
    }
  }
}

✅ Kingfisher

캐시(cache)
주기억 장치에 읽어들인 명령이나 프로그램들로 채워지는 버퍼 형태의 고속 기억 장치. 중앙 처리 장치가 명령이 필요하게 되면, 맨 먼저 액세스하는 것은 주기억 장치가 아니라 캐시 메모리이다. 자주 액세스하는 데이터나 프로그램 명령을 반복해서 검색하지 않고도 즉각 사용할 수 있도록 저장해두는 영역이다.
[네이버 지식백과] 캐시 [cache] (컴퓨터인터넷IT용어대사전, 2011. 1. 20., 전산용어사전편찬위원회)

  • 이미지 캐싱을 쉽게 할 수 있게 해주는 라이브러리.
  • UIImageView, NSImageView, UIButton, NsButton에서 사용 가능.
let url = URL(string:https://firebasestorage.googleapis.com/9a567ad81b57)
imageView.kf.setImage(with: url)

Kingfisher 실행 순서

https://1consumption.github.io/posts/about-kingfisher(1)/ 를 참고하였습니다.

  1. url.absoluteString을 key로 캐싱 된 이미지가 있는지 확인합니다.
  2. 만약 cache(memory나 disk)에서 이미지를 찾으면 imageView.image에 설정해줍니다.
  3. 그렇지 않다면, request를 생성하고 url로 부터 다운로드합니다.
  4. 다운로드된 데이터를 UIImage 객체로 변환합니다.
  5. memory cache에 이미지를 cache하고 disk cache에 데이터를 저장합니다.
  6. 이미지를 표시하기 위해 imageView.image를 설정하세요.

0개의 댓글