diff --git a/Projects/Features/Recommend/Sources/Presentation/Extension/View+Extenstion.swift b/Projects/Features/Recommend/Sources/Presentation/Extension/View+Extenstion.swift index 3710e76..3a157df 100644 --- a/Projects/Features/Recommend/Sources/Presentation/Extension/View+Extenstion.swift +++ b/Projects/Features/Recommend/Sources/Presentation/Extension/View+Extenstion.swift @@ -29,3 +29,16 @@ struct SizePreferenceKey: PreferenceKey { static var defaultValue: CGSize = .zero static func reduce(value: inout CGSize, nextValue: () -> CGSize) { } } + +extension View { + func recommendSkeleton( + isShow: Bool, + radius: CGFloat, + width: CGFloat? = nil, + height: CGFloat? = nil + ) -> some View { + self.modifier( + SkeletonModifier(isShow: isShow, radius: radius, width: width, height: height) + ) + } +} diff --git a/Projects/Features/Recommend/Sources/Presentation/Modifier/SkeletonModifier.swift b/Projects/Features/Recommend/Sources/Presentation/Modifier/SkeletonModifier.swift new file mode 100644 index 0000000..b326018 --- /dev/null +++ b/Projects/Features/Recommend/Sources/Presentation/Modifier/SkeletonModifier.swift @@ -0,0 +1,34 @@ +// +// SkeletonModifier.swift +// Recommend +// +// Created by 김종윤 on 8/8/24. +// + +import SwiftUI +import SkeletonUI + +import ResourceKit + +struct SkeletonModifier: ViewModifier { + let isShow: Bool + let radius: CGFloat + let width: CGFloat? + let height: CGFloat? + + func body(content: Content) -> some View { + content + .skeleton( + with: isShow, + animation: .linear(duration: 2, delay: 0, speed: 1), + appearance: .gradient( + .linear, + color: Color.Skeleton.home, + background: Color.Skeleton.homeback, + radius: 1 + ), + shape: .rounded(.radius(radius)) + ) + .frame(width: width, height: height) + } +} diff --git a/Projects/Features/Recommend/Sources/Presentation/RecommendHeaderView.swift b/Projects/Features/Recommend/Sources/Presentation/RecommendHeaderView.swift index 0b48bc3..0c40c17 100644 --- a/Projects/Features/Recommend/Sources/Presentation/RecommendHeaderView.swift +++ b/Projects/Features/Recommend/Sources/Presentation/RecommendHeaderView.swift @@ -6,6 +6,8 @@ // import SwiftUI +import SkeletonUI + import ResourceKit struct RecommendHeaderView: View { @@ -15,6 +17,8 @@ struct RecommendHeaderView: View { @Binding var recommendMemeSize: Int public var body: some View { + let isNotLoading = userLevel == 0 || seenMemeCount == 0 + VStack(spacing: 0) { ResourceKitAsset.Icon.homeLogo.swiftUIImage .resizable() @@ -22,15 +26,20 @@ struct RecommendHeaderView: View { .padding(.bottom, 10) recommendTitle - .padding(.bottom, 16) - - recommendProgressBar( - seenMemeCount: self.seenMemeCount, - total: recommendMemeSize - ) - .padding(.bottom, 8) - recommendText(getRecommendText()) + if !isNotLoading { + recommendProgressBar( + seenMemeCount: self.seenMemeCount, + total: recommendMemeSize + ) + + recommendText(getRecommendText()) + } else { + EmptyView() + .recommendSkeleton(isShow: isNotLoading, radius: 4, width: 200, height: 16) + .padding(.top, 20) + .padding(.bottom, 37) + } } } @@ -41,7 +50,7 @@ struct RecommendHeaderView: View { (1...(recommendMemeSize - 1)).contains(seenMemeCount) { "밈 보고 레벨 포인트 받아요!" - } else if userLevel == 2 && + } else if userLevel == 2 && (1...(recommendMemeSize - 1)).contains(seenMemeCount) { "추천 밈 둘러보세요!" @@ -54,7 +63,7 @@ struct RecommendHeaderView: View { private var recommendTitle : some View { Text("이번 주 이 밈 어때!") .font(Font.Heading.Large.semiBold) - .padding(.bottom, 8) + .padding(.bottom, 16) } private func recommendProgressBar( @@ -83,10 +92,12 @@ private func recommendText(_ text: String) -> some View { Text(text) .font(Font.Body.Medium.medium) .foregroundStyle(Color.Text.secondary) + .padding(.top, 8) + .padding(.bottom, 32) } #Preview { - @State var userLevel: Int = 1 + @State var userLevel: Int = 0 @State var seenMemeCount: Int = 5 @State var recommendMemeSize: Int = 5 @@ -95,4 +106,16 @@ private func recommendText(_ text: String) -> some View { seenMemeCount: $seenMemeCount, recommendMemeSize: $recommendMemeSize ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + LinearGradient( + colors: [ + Color.Background.brandassistive, + Color.Background.brandsubassistive + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .edgesIgnoringSafeArea(.bottom) } diff --git a/Projects/Features/Recommend/Sources/Presentation/RecommendMemeButtonView.swift b/Projects/Features/Recommend/Sources/Presentation/RecommendMemeButtonView.swift index a98b939..fce6fd8 100644 --- a/Projects/Features/Recommend/Sources/Presentation/RecommendMemeButtonView.swift +++ b/Projects/Features/Recommend/Sources/Presentation/RecommendMemeButtonView.swift @@ -62,7 +62,7 @@ struct RecommendMemeButtonView : View { } } .frame(maxWidth: .infinity) - .frame(height: 110, alignment: .center) + .padding(.vertical, 30) .background( LinearGradient( colors: [ diff --git a/Projects/Features/Recommend/Sources/Presentation/RecommendMemeImageView.swift b/Projects/Features/Recommend/Sources/Presentation/RecommendMemeImageView.swift index e7e2106..4c5a78a 100644 --- a/Projects/Features/Recommend/Sources/Presentation/RecommendMemeImageView.swift +++ b/Projects/Features/Recommend/Sources/Presentation/RecommendMemeImageView.swift @@ -18,6 +18,8 @@ struct RecommendMemeImagesView: View { @State var value: CGFloat = 0 + @State var imageStatusList: [String : Bool] = [:] + var memes: [MemeDetail] var isTagHidden: Bool = false @@ -30,14 +32,9 @@ struct RecommendMemeImagesView: View { ForEach(memes, id: \.self) { meme in MemeImageView( imageUrl: meme.imageUrlString, - isDimmed: meme.id != currentMeme?.id + isDimmed: meme.id != currentMeme?.id, + isLoadingImage: binding(for: meme.id) ) - .scrollTransition { content, phase in - content - .offset(x: phase.value * -3) - .scaleEffect(phase.isIdentity ? 1 : 0.9) - .blur(radius: phase.isIdentity ? 0 : 1) - } .animation(.smooth, value: meme) } } @@ -45,42 +42,60 @@ struct RecommendMemeImagesView: View { // Border HStack(spacing: 0) { ForEach(memes, id: \.self) { meme in - RoundedRectangle(cornerRadius: 20) - .inset(by: 1) - .stroke(Color.Border.primary, lineWidth: 2) - .scrollTransition { content, phase in - content - .offset(x: phase.value * -3) - .scaleEffect(phase.isIdentity ? 1 : 0.905) - } - .animation(.smooth, value: meme) + MemeImageBorderView( + isLoadingImage: binding(for: meme.id) + ) + .animation(.smooth, value: meme) } } } - .frame(height: 310) .scrollTargetLayout() + .recommendSkeleton( + isShow: memes.isEmpty, + radius: 20, + width: memes.isEmpty ? 270 : .infinity, + height: 310 + ) } .scrollIndicators(.never) .scrollTargetBehavior(.viewAligned) .scrollPosition(id: $currentMeme) .contentMargins(.horizontal, 60.0) - .padding(.top, 36) .padding(.bottom, 20) if let currentMeme, isTagHidden == false { HashTagView(keywords: currentMeme.keywords) + } else { + EmptyView() + .recommendSkeleton(isShow: true, radius: 4, width: 200, height: 16) } } - .onAppear { - currentMeme = memes.first - } .onChange(of: memes) { _, value in - let current = value.first(where: { - $0.id == currentMeme?.id - }) - currentMeme = current + if let currentMeme { + let current = value.first(where: { + $0.id == currentMeme.id + }) + self.currentMeme = current + + } else { + self.currentMeme = memes.first + memes.forEach { meme in + imageStatusList[meme.id] = false + } + } } } + + func binding(for key: String) -> Binding { + return Binding( + get: { + return self.imageStatusList[key] ?? false + }, + set: { + self.imageStatusList[key] = $0 + } + ) + } } #Preview { @@ -157,4 +172,16 @@ struct RecommendMemeImagesView: View { ], isTagHidden: false ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + LinearGradient( + colors: [ + Color.Background.brandassistive, + Color.Background.brandsubassistive + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .edgesIgnoringSafeArea(.bottom) } diff --git a/Projects/Features/Recommend/Sources/Presentation/RecommendView.swift b/Projects/Features/Recommend/Sources/Presentation/RecommendView.swift index 53da691..fd0d684 100644 --- a/Projects/Features/Recommend/Sources/Presentation/RecommendView.swift +++ b/Projects/Features/Recommend/Sources/Presentation/RecommendView.swift @@ -24,6 +24,10 @@ public struct RecommendView: View { @State private var memeImageHeight: CGFloat = 0 @State private var buttonViewHeight: CGFloat = 0 + var isOverlapView: Bool { + memeImageHeight + buttonViewHeight > memeContentsHeight + 30 + } + @State private var currentMeme: MemeDetail? @State var isActiveCopyPopup: Bool = false @State var isFarmemed: Bool = false @@ -41,9 +45,7 @@ public struct RecommendView: View { VStack(spacing: 0) { Spacer() - if viewModel.state.recommendMemeSize > 0 && - !viewModel.state.isSuccessFetch - { + if viewModel.state.recommendMemeSize > 0 && !viewModel.state.isSuccessFetch { ProgressView() .frame(width: 30, height: 30, alignment: .center) .padding(.bottom, 20) @@ -56,18 +58,15 @@ public struct RecommendView: View { ) ZStack { - let isOverlapView = memeImageHeight + buttonViewHeight > memeContentsHeight + 30 VStack(spacing: 0) { - if viewModel.state.recommendMemes.count > 0 { - RecommendMemeImagesView( - currentMeme: $currentMeme, - memes: viewModel.state.recommendMemes, - isTagHidden: isOverlapView - ) - .onReadSize { size in - memeImageHeight = size.height - } + RecommendMemeImagesView( + currentMeme: $currentMeme, + memes: viewModel.state.recommendMemes, + isTagHidden: isOverlapView + ) + .onReadSize { size in + memeImageHeight = size.height } Spacer() @@ -77,24 +76,25 @@ public struct RecommendView: View { VStack(spacing: 0) { Spacer() - RecommendMemeButtonView( - isReaction: currentMeme?.isReaction ?? false, - reactionCnt: currentMeme?.reaction ?? 0, - isFarmemed: currentMeme?.isFarmemed ?? false, - isOverlapView: isOverlapView, - reactionButtonTapped: reactionButtonTap, - copyButtonTapped: copyButtonTap, - shareButtonTapped : shareButtonTap, - saveButtonTapped : saveButtonTap - ) - .onReadSize { size in - print(size.height) - buttonViewHeight = size.height + if let currentMeme { + RecommendMemeButtonView( + isReaction: currentMeme.isReaction, + reactionCnt: currentMeme.reaction, + isFarmemed: currentMeme.isFarmemed, + isOverlapView: isOverlapView, + reactionButtonTapped: reactionButtonTap, + copyButtonTapped: copyButtonTap, + shareButtonTapped : shareButtonTap, + saveButtonTapped : saveButtonTap + ) + .onReadSize { size in + buttonViewHeight = size.height + } } } .zIndex(2) } - .frame(maxHeight: 490) + .frame(maxHeight: 457) .onReadSize { size in memeContentsHeight = size.height } @@ -153,7 +153,9 @@ public struct RecommendView: View { if value.translation.height < 0 { return } withAnimation(.spring()) { - currentOffsetY = value.translation.height > 180 ? 180 : value.translation.height + currentOffsetY = value.translation.height > 180 + ? 180 + : value.translation.height } }) .onEnded({ value in diff --git a/Projects/Features/Recommend/Sources/Presentation/View/MemeImageView.swift b/Projects/Features/Recommend/Sources/Presentation/View/MemeImageView.swift index 0047703..def37af 100644 --- a/Projects/Features/Recommend/Sources/Presentation/View/MemeImageView.swift +++ b/Projects/Features/Recommend/Sources/Presentation/View/MemeImageView.swift @@ -13,26 +13,67 @@ struct MemeImageView: View { let imageUrl: String let isDimmed: Bool + @State var image: URL? = nil + + @Binding var isLoadingImage: Bool + public var body: some View { Rectangle() .frame(width: 270, height: 310) .cornerRadius(20) + .foregroundColor(.black.opacity(isLoadingImage ? 1 : 0)) .overlay { KFImage(URL(string: imageUrl)) + .placeholder { + EmptyView() + .recommendSkeleton(isShow: true, radius: 20, width: 270, height: 310) + } + .onSuccess { _ in + isLoadingImage = true + } .resizable() .aspectRatio(contentMode: .fit) - if isDimmed { + if isDimmed && isLoadingImage { RoundedRectangle(cornerRadius: 20) .foregroundStyle(Color.Background.dimmer) } } + .scrollTransition { content, phase in + content + .offset(x: phase.value * -3) + .scaleEffect(phase.isIdentity ? 1 : 0.9) + .blur( + radius: (!isLoadingImage || phase.isIdentity) ? 0 : 1 + ) + } + } +} + +struct MemeImageBorderView: View { + + @Binding var isLoadingImage: Bool + + public var body: some View { + RoundedRectangle(cornerRadius: 20) + .inset(by: 1) + .stroke( + Color.Border.primary.opacity(isLoadingImage ? 1 : 0), + lineWidth: 2 + ) + .scrollTransition { content, phase in + content + .offset(x: phase.value * -3) + .scaleEffect(phase.isIdentity ? 1 : 0.905) + } } } #Preview { MemeImageView( imageUrl: "https://ppac-meme.s3.ap-northeast-2.amazonaws.com/17204513204087.jpg", - isDimmed: false +// imageUrl: "", + isDimmed: false, + isLoadingImage: .constant(false) ) } diff --git a/Projects/ResourceKit/Resources/PrimaryColor.xcassets/Skeleton100.colorset/Contents.json b/Projects/ResourceKit/Resources/PrimaryColor.xcassets/Skeleton100.colorset/Contents.json new file mode 100644 index 0000000..9486927 --- /dev/null +++ b/Projects/ResourceKit/Resources/PrimaryColor.xcassets/Skeleton100.colorset/Contents.json @@ -0,0 +1,23 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.851", + "green" : "0.902", + "red" : "0.957" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "localizable" : true + } +} diff --git a/Projects/ResourceKit/Resources/PrimaryColor.xcassets/Skeleton50.colorset/Contents.json b/Projects/ResourceKit/Resources/PrimaryColor.xcassets/Skeleton50.colorset/Contents.json new file mode 100644 index 0000000..f4b6681 --- /dev/null +++ b/Projects/ResourceKit/Resources/PrimaryColor.xcassets/Skeleton50.colorset/Contents.json @@ -0,0 +1,23 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.902", + "green" : "0.941", + "red" : "0.976" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "localizable" : true + } +} diff --git a/Projects/ResourceKit/Sources/SemanticColor.swift b/Projects/ResourceKit/Sources/SemanticColor.swift index 24775f9..204f09f 100644 --- a/Projects/ResourceKit/Sources/SemanticColor.swift +++ b/Projects/ResourceKit/Sources/SemanticColor.swift @@ -49,6 +49,8 @@ public struct Color { public struct Skeleton { public static let primary = ResourceKitAsset.PrimaryColor.neutral10.swiftUIColor public static let secondary = ResourceKitAsset.PrimaryColor.neutral20.swiftUIColor + public static let home = ResourceKitAsset.PrimaryColor.skeleton50.swiftUIColor + public static let homeback = ResourceKitAsset.PrimaryColor.skeleton100.swiftUIColor } }