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 |