circlecircle
원형
circlecircle
전체 방문자
오늘
어제
  • 분류 전체보기 (11)
    • Flutter (2)
      • Firebase (1)
    • iOS (9)
      • swiftUI (6)
      • UIKit (0)
      • Firebase (3)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • LazyVStack
  • SwiftUI
  • Stack
  • StateObject
  • storage
  • userdefaults
  • Cloud FireStore
  • ios
  • Combine
  • SPM
  • Kingfisher
  • propertyWrapper
  • 다중이미지
  • AppStorage
  • Firebase
  • EnvironmentObject
  • SDWebImage
  • ObservableObject

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
circlecircle

원형

iOS/Firebase

[SwiftUI] Firebase Storage 다중이미지 upload & retrieve, 별점 리뷰 글 posting

2023. 1. 29. 03:53

 

Firestore database는 1.5mb 이하의 데이터만 저장이 가능하기에 이미지를 업로드 하지 않고 database에는 이미지의 uuid를 배열로 등록하여 저장하고 일반 하드인 Storage에 업로드 해야한다.

retreive시에는 Storage에 저장된 UUID로 생성한 review.id, image.id의 url을 불러와 database에 저장한다.


1. 먼저 이미지 업로드 전에 Review에 들어갈 속성들을 구조체로 만든다.

review(post)마다 uuid, 로그인 사용자 판별 id, 리뷰 글, 다중 이미지 처리하기위한 배열, 사용자의 닉네임, 별점(깍두기 이미지 대체)

createdDate는 작성된 한국 시간별로 리뷰를 분류하기 위해 사용한다.  

import Foundation
import SwiftUI

struct Review: Codable, Identifiable, Hashable {
    var id: String
    var userId: String
    var reviewText: String
    var createdAt: Double
    var images: [String]?
    var nickName: String
    var starRating: Int
    
    var createdDate: String {
        let dateFormatter = DateFormatter()
        dateFormatter.locale = Locale(identifier: "ko_kr")
        dateFormatter.timeZone = TimeZone(abbreviation: "KST")
        dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" // "yyyy-MM-dd HH:mm:ss"
        
        let dateCreatedAt = Date(timeIntervalSince1970: createdAt)
        
        return dateFormatter.string(from: dateCreatedAt)
    }
    
}

2. createReview

 
 
 

 

import SwiftUI
import PhotosUI
import PopupView
import Shimmer

struct CreateReviewView: View {
    @EnvironmentObject private var userViewModel: UserViewModel
    @StateObject var reviewViewModel: ReviewViewModel
    @ObservedObject var starStore: StarStore
    
    @State private var selectedImages: [PhotosPickerItem] = []
    @State private var selectedImageData: [Data] =  []
    
    @State private var isReviewAdded: Bool = false
    @State private var reviewText: String = ""
    @State private var selectedImagesDetail: Bool = false
    
    @Binding var showingSheet: Bool
    
    
    
    
    
    var trimReviewText: String {
        reviewText.trimmingCharacters(in: .whitespaces)
    }
    var images: [UIImage]  {
        var uiImages: [UIImage] = []
        if !selectedImageData.isEmpty {
            for imageData in selectedImageData {
                if let image = UIImage(data: imageData) {
                    uiImages.append(image)
                }
            }
        }
        return uiImages
    }
    var body: some View {
        NavigationStack {
            VStack{
                VStack{
                    HStack{
                        Spacer()
                        Text("")
                        Spacer()
                    }
                    HStack(spacing: 15) {
                        Spacer()
                        
                        ForEach(0..<5) { index in
                            Image(starStore.selectedStar >= index ? "Ggakdugi" : "Ggakdugi.gray")
                                .resizable()
                                .frame(width: 40, height: 40)
                                .onTapGesture {
                                    starStore.selectedStar = index
                                }
                        }
                        Spacer()
                    }
                    
                    .padding(EdgeInsets(top: 0, leading: 0, bottom: 20, trailing: 0))
                    Text("\(starStore.selectedStar + 1) / \(5)")
                        .font(.system(size: 17))
                        .fontWeight(.semibold)
                }//VStack
                .padding(.top,30)
                HStack {
                    VStack(alignment: .center){
                        PhotosPicker(
                            selection: $selectedImages,
                            maxSelectionCount: 5,
                            matching: .images,
                            photoLibrary: . shared()){
                                Image(systemName: "camera")
                                    .foregroundColor(.yellow)
                                    .font(.system(size: 25))
                                    .frame(width:70, height: 40, alignment: .center)
                                
                                
                                
                            }//photoLibrary
                        HStack{
                            if selectedImages.count == 0{
                                Text("\(selectedImages.count)")
                                    .font(.callout)
                                    .foregroundColor(selectedImages.count == 0 ? .gray : .yellow)
                                    .fontWeight(.regular)
                                    .padding(.trailing,-8)
                                Text("/5")
                                    .font(.callout)
                                    .fontWeight(.regular)
                            }
                            else {
                                Text("\(selectedImages.count)")
                                    .font(.callout)
                                    .foregroundColor(selectedImages.count == 0 ? .gray : .black)
                                    .fontWeight(.regular)
                                    .padding(.trailing,-8)
                                    .shimmering(
                                        animation: .easeInOut(duration: 2).repeatCount(5, autoreverses: false).delay(1)
                                    )
                                Text("/5")
                                    .font(.callout)
                                    .fontWeight(.regular)
                            }
                        }
                        .tracking(5)
                        .padding(.bottom,10)
                        .padding(.top,-10)
                        .padding(.leading,4)
                    }
                    .background(RoundedRectangle(cornerRadius: 5.0).stroke(Color.yellow,lineWidth: 1.5))
                    .onChange(of: selectedImages) { items in
                        //선택된 이미지 없으면 배열 초기화
                        if items.isEmpty { selectedImageData = [] }
                        
                        for item in items {
                            Task {
                                selectedImageData = []
                                if let data = try? await
                                    item.loadTransferable(type: Data.self) {
                                    selectedImageData.append(data)
                                    
                                }
                            }//Task
                        }//for
                    }//.onChanged
                    .padding(.leading,7)
                    ScrollView(.horizontal, showsIndicators: false) {
                        HStack(spacing: 10) {
                            // 선택된 이미지 출력.
                            //  ForEach(selectedImageData, id: \.self) { imageData in
                            ForEach(Array(selectedImageData.enumerated()), id: \.offset) { index, imageData in
                                if let image = UIImage(data: imageData) {
                                    NavigationLink {
                                        ImageDetailView()
                                    }
                                label:{
                                    Image(uiImage: image)
                                        .resizable()
                                        .cornerRadius(4)
                                    // .scaledToFit()
                                        .frame(width: 70,height: 70)
                                        .overlay(alignment: .topTrailing) {
                                            Button(action: {
                                                selectedImageData.remove(at:index)
                                                selectedImages.remove(at: index)
                                                
                                            }) {
                                                Circle()
                                                    .frame(width: 17, height: 17)
                                                    .foregroundColor(.black)
                                                    .overlay {
                                                        Image(systemName: "xmark")
                                                            .font(.system(size: 12))
                                                            .foregroundColor(.white)
                                                    }
                                                
                                                
                                            }
                                            
                                        }//overlay
                                }//label
                                    
                                    
                                    //.offset(x: 5, y: -5)
                                    
                                .overlay(alignment: .bottom) {
                                    if (selectedImages.first != nil) {
                                        if (selectedImageData.first != nil) {
                                            if index == 0 {
                                                Text("대표 사진")
                                                    .font(.system(size:12))
                                                    .fontWeight(.regular)
                                                    .frame(maxWidth: .infinity)
                                                    .frame(height: 20)
                                                    .foregroundColor(Color.white)
                                                    .background { Color.black }
                                                    .cornerRadius(4)
                                                    .shimmering(
                                                        animation: .easeInOut(duration: 2).repeatCount(10, autoreverses: false).delay(0.5)
                                                    )
                                            }
                                        }
                                    }
                                }
                                } // if let
                                
                            } // FirstForEach
                            
                        } // HStack
                    }//ScrollView
                    .frame(height: 70)
                } // HStack
                .padding(EdgeInsets(top: 30, leading: 20, bottom: 50, trailing: 20))
                VStack {
                    Section {
                        TextField("작성된 리뷰는 우리 모두가 확인할 수 있어요. 국밥 같은 따뜻한 마음을 나눠주세요.", text: $reviewText, axis: .vertical)
                            .frame(width: 300, height: 250, alignment: .center)
                            .padding(EdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 20))
                            .background(RoundedRectangle(cornerRadius: 5.0).stroke(Color.yellow, lineWidth: 1.5))
                            .multilineTextAlignment(.leading)
                            .autocapitalization(.none)
                            .disableAutocorrection(true)
                            .lineLimit(11...)
                    }
                    .navigationTitle("농민백암순대")
                    .navigationBarTitleDisplayMode(.inline)
                    
                    .toolbar {
                        ToolbarItem(placement: .navigationBarLeading) {
                            Button("취소") {
                                showingSheet.toggle()
                            }
                        }
                        if trimReviewText.count > 0 {
                            ToolbarItem(placement: .navigationBarTrailing) {
                                Button("등록") {
                                    Task{
                                        
                                        let createdAt = Date().timeIntervalSince1970
                                        
                                        let review: Review = Review(id: UUID().uuidString,
                                                                    userId: userViewModel.userInfo.id,
                                                                    reviewText: reviewText,
                                                                    createdAt: createdAt,
                                                                    nickName: userViewModel.userInfo.userNickname,
                                                                    starRating:  starStore.selectedStar)
                                        
                                        await reviewViewModel.addReview(review: review, images: images)
                                        
                                        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
                                            showingSheet.toggle()
                                        }
                                        isReviewAdded.toggle()
                                    }
                                }
                            }
                        }//if
                    }//toolbar
                }//VStack
                
                Spacer()
                
            }//FirstVStack
            .popup(isPresented: $isReviewAdded) {
                HStack {
                    Image(systemName: "checkmark")
                        .foregroundColor(.white)
                    Text("리뷰가 작성되었습니다.")
                        .foregroundColor(.white)
                        .font(.footnote)
                        .bold()
                }
                .padding(EdgeInsets(top: 10, leading: 20, bottom: 10, trailing: 20))
                .background(Color.yellow)
                .cornerRadius(100)
            } customize: {
                $0
                    .autohideIn(2)
                    .type(.floater())
                    .position(.top)
            } // popup
        }//NavigationStack
        .fullScreenCover(isPresented: $selectedImagesDetail){
            ImageDetailView()
        }
        .onDisappear{
            reviewViewModel.fetchReviews()
        }
        
    }//body
}//struct CreateReviewView

3.  reviewViewModel 

firebase에 review 구조체 add(uid, text, images, createdAt, nickName)

올려진 후 비동기 처리로 바로 fetch, contextMenu로 delete 코드 

import Foundation
import FirebaseStorage
import SwiftUI
import Firebase
import FirebaseFirestore

class ReviewViewModel: ObservableObject {
    @Published var reviews: [Review] = []
    @Published var reviewImage: [String : UIImage] = [:]
    
    let database = Firestore.firestore()
    let storage = Storage.storage()
    
    init() {
        reviews = []
    }
    
    //    var id: String
    //    var userId: String
    //    var reviewText: String
    //    var createdAt: Double
    //    var image: [String]?
    //    var nickName: String
    //    var createdDate: String
    
    func fetchReviews() {
        
        database.collection("Review")
            .order(by: "createdAt", descending: true)
            .getDocuments { (snapshot, error) in
                self.reviews.removeAll()
                
                if let snapshot {
                    for document in snapshot.documents {
                        let id: String = document.documentID
                        
                        let docData = document.data()
                        let userId: String = docData["userId"] as? String ?? ""
                        let reviewText: String = docData["reviewText"] as? String ?? ""
                        let createdAt: Double = docData["createdAt"] as? Double ?? 0
                        let images: [String] = docData["images"] as? [String] ?? []
                        let nickName: String = docData["nickName"] as? String ?? ""
                        let starRating: Int = docData["starRating"] as? Int ?? 0
                        
                        for imageName in images{
                            self.retrieveImages(reviewId: id, imageName: imageName)
                        }
                        
                        let review: Review = Review(id: id,
                                                    userId: userId,
                                                    reviewText: reviewText,
                                                    createdAt: createdAt,
                                                    images: images,
                                                    nickName: nickName,
                                                    starRating: starRating )
                        
                        self.reviews.append(review)
                        print("reviews배열@@@@@@@ \(self.reviews)")
                    }
                }
            }
    }
    
    // MARK: - 서버의 Reviews Collection에 Reviews 객체 하나를 추가하여 업로드하는 Method
    func addReview(review: Review, images: [UIImage]) async {
        do {
            var imgNameList: [String] = []
            
            for img in images {
                let imgName = UUID().uuidString
                imgNameList.append(imgName)
                uploadImage(image: img, name: (review.id + "/" + imgName))
            }
            
            try await database.collection("Review")
                .document(review.id)
                .setData(["userId": review.userId,
                          "reviewText": review.reviewText,
                          "createdAt": review.createdAt,
                          "images": imgNameList,
                          "nickName": review.nickName,
                          "starRating": review.starRating
                         ])
            fetchReviews()
            print("이미지 배열\(imgNameList)")
        } catch {
            print(error.localizedDescription)
        
        }
    
    }
    
    // MARK: - 서버의 Reviews Collection에서 Reviews 객체 하나를 삭제하는 Method
    func removeReview(review: Review) {
        database.collection("Reviews")
            .document(review.id).delete()
        
        // remove photos from storage
        if let images = review.images {
            for image in images {
                let imagesRef = storage.reference().child("images/\(review.id)/\(image)")
                imagesRef.delete { error in
                    if let error = error {
                        print("Error removing image from storage\n\(error.localizedDescription)")
                    } else {
                        print("images directory deleted successfully")
                    }
                }
            }
        }
        fetchReviews()
    }
    
    // MARK: - 서버의 Storage에 이미지를 업로드하는 Method
    func uploadImage(image: UIImage, name: String) {
        let storageRef = storage.reference().child("images/\(name)")
        let data = image.jpegData(compressionQuality: 0.1)
        let metadata = StorageMetadata()
        metadata.contentType = "image/jpg"
        
        // uploda data
        if let data = data {
            storageRef.putData(data, metadata: metadata) { (metadata, err) in
                
                if let err = err {
                    print("err when uploading jpg\n\(err)")
                }
                
                if let metadata = metadata {
                    print("metadata: \(metadata)")
                }
            }
        }
        
    }

    
    // MARK: - 서버의 Storage에서 이미지를 가져오는 Method
    func retrieveImages(reviewId: String, imageName: String) {
        print("이미지 패치 함수 실행됨")
        let ref = storage.reference().child("images/\(reviewId)/\(imageName)")
        
        // Download in memory with a maximum allowed size of 1MB (1 * 1024 * 1024 bytes)
        ref.getData(maxSize: 15 * 1024 * 1024) { data, error in
            if let error = error {
                print("error while downloading image\n\(error.localizedDescription)")
                return
            } else {
                let image = UIImage(data: data!)
                self.reviewImage[imageName] = image
            }
        }
    }
}

4. View에서 불러오기 

로그인을 하지 않아 닉네임이 공백으로 표기되며,
이미지가 3개 이상일 시 +n개의 이미지로 blur처리 된다.
왼쪽으로 scroll 하면 geometry로 +2의 글자가 animation으로 다시 사라진다.
struct UserReview:  View {
    @StateObject var reviewViewModel: ReviewViewModel
    @ObservedObject var starStore = StarStore()
    @Binding var scrollViewOffset: CGFloat
    
    var index: Int
    var review: Review
    
    var body: some View {
        VStack{
            HStack{
                Text("\(review.nickName)")
                    .foregroundColor(.black)
                    .padding()
                Spacer()
                Text("\(review.createdDate)")
                    .font(.footnote)
                    .foregroundColor(.secondary)
                    .padding()
            }
            
            HStack(spacing: -30){
                ForEach(0..<5) { index in
                    Image(review.starRating >= index ? "Ggakdugi" : "Ggakdugi.gray")
                        .resizable()
                        .frame(width: 15, height: 15)
                        .padding()
                }
                Spacer()
            }//HStack
            .padding(.top,-30)
                                
                ScrollView(.horizontal, showsIndicators: false){
                    HStack{
                        ForEach(Array(review.images!.enumerated()), id: \.offset) { index, imageData in
                                if let image = reviewViewModel.reviewImage[imageData] {
                                    
                                        Image(uiImage: image)
                                            .resizable()
                                            .frame(width: 180,height: 160)
                                            .cornerRadius(10)
                                    
                                            .overlay() {
                                                if ((review.images?.count ?? 0) > 2)  && index == 1 {
                                                    
                                                    RoundedRectangle(cornerRadius: 10)
                                                        .fill(Color.black.opacity(0.2))
                                                    
                                                    let remainImages = (review.images?.count ?? 0) - 2
                                                    if -scrollViewOffset == 0 {
                                                        
                                                        Text("+\(remainImages)")
                                                            .font(.title)
                                                            .fontWeight(.heavy)
                                                            .foregroundColor(.white)
                                                    }
                                                }//Second 'if'
                                            }//overlay
                                }//if let

                            }// ForEach(review.images)
                    }
                }//scrollView
                .padding(.top,-15)
                .padding(.leading,15)
          
            HStack{
                Text("\(review.reviewText)")
                    .font(.footnote)
                    .foregroundColor(.black)
                    .padding()
                Spacer()
            }
            
            Divider()
        }//VStack
    }
}

'iOS > Firebase' 카테고리의 다른 글

[SwiftUI] Firebase ViewModel 'Combine' snapShot Listener(파이어베이스 스냅샷 리스너)  (1) 2023.02.05
[SwiftUI] Firebase Storage Post Image upload & retrieve 단일이미지  (0) 2022.12.18
    'iOS/Firebase' 카테고리의 다른 글
    • [SwiftUI] Firebase ViewModel 'Combine' snapShot Listener(파이어베이스 스냅샷 리스너)
    • [SwiftUI] Firebase Storage Post Image upload & retrieve 단일이미지
    circlecircle
    circlecircle

    티스토리툴바