feat(main-home): 추천 홈 공용 컴포넌트를 추가한다

This commit is contained in:
Yu Sung
2026-06-12 15:08:22 +09:00
parent 016a8bcca3
commit ed5e92e1d6
15 changed files with 748 additions and 4 deletions

View File

@@ -0,0 +1,58 @@
import SwiftUI
struct BannerCarouselItem: Identifiable, Hashable {
let id: String
let imageUrl: String?
init(id: String, imageUrl: String?) {
self.id = id
self.imageUrl = imageUrl
}
}
struct BannerCarousel: View {
let items: [BannerCarouselItem]
let height: CGFloat
let action: (BannerCarouselItem) -> Void
init(
items: [BannerCarouselItem],
height: CGFloat = 120,
action: @escaping (BannerCarouselItem) -> Void = { _ in }
) {
self.items = items
self.height = height
self.action = action
}
var body: some View {
if !items.isEmpty {
TabView {
ForEach(items) { item in
Button {
action(item)
} label: {
DownsampledKFImage(
url: URL(string: item.imageUrl ?? ""),
size: CGSize(width: UIScreen.main.bounds.width - (SodaSpacing.s20 * 2), height: height)
)
.background(Color.gray800)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
.buttonStyle(.plain)
.padding(.horizontal, SodaSpacing.s20)
}
}
.frame(height: height)
.tabViewStyle(.page(indexDisplayMode: items.count > 1 ? .automatic : .never))
}
}
}
struct BannerCarousel_Previews: PreviewProvider {
static var previews: some View {
BannerCarousel(items: [BannerCarouselItem(id: "1", imageUrl: nil)])
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,58 @@
import SwiftUI
struct FollowAllButton: View {
let isCompleted: Bool
let isLoading: Bool
let action: () -> Void
init(
isCompleted: Bool,
isLoading: Bool = false,
action: @escaping () -> Void
) {
self.isCompleted = isCompleted
self.isLoading = isLoading
self.action = action
}
var body: some View {
Button {
if !isCompleted && !isLoading {
action()
}
} label: {
HStack(spacing: SodaSpacing.s6) {
if isCompleted {
Image("ic_new_following")
.resizable()
.renderingMode(.template)
.foregroundColor(.white)
.frame(width: 16, height: 16)
}
Text(isCompleted ? I18n.HomeRecommendation.followAllCompleted : I18n.HomeRecommendation.followAll)
.appFont(.body5)
.foregroundColor(.white)
}
.padding(.horizontal, SodaSpacing.s16)
.frame(height: 36)
.background(isCompleted ? Color.gray700 : Color.button)
.clipShape(Capsule())
.opacity(isLoading ? 0.6 : 1)
}
.buttonStyle(.plain)
.disabled(isCompleted || isLoading)
}
}
struct FollowAllButton_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: SodaSpacing.s12) {
FollowAllButton(isCompleted: false) {}
FollowAllButton(isCompleted: true) {}
}
.padding(SodaSpacing.s20)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,66 @@
import SwiftUI
struct AiCharacterCard: View {
let name: String
let description: String
let profileImageUrl: String?
let chatCount: Int?
let originalTitle: String?
var body: some View {
HStack(alignment: .top, spacing: SodaSpacing.s12) {
DownsampledKFImage(url: URL(string: profileImageUrl ?? ""), size: CGSize(width: 72, height: 72))
.background(Color.gray800)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
VStack(alignment: .leading, spacing: SodaSpacing.s4) {
Text(name)
.appFont(.heading4)
.foregroundColor(.white)
.lineLimit(1)
.truncationMode(.tail)
Text(description)
.appFont(.body5)
.foregroundColor(Color.gray500)
.lineLimit(2)
.truncationMode(.tail)
HStack(spacing: SodaSpacing.s8) {
if let chatCount {
Text("Chat \(chatCount)")
.appFont(.caption2)
.foregroundColor(Color.gray500)
}
if let originalTitle, !originalTitle.isEmpty {
Text(originalTitle)
.appFont(.caption2)
.foregroundColor(Color.gray500)
.lineLimit(1)
}
}
}
Spacer(minLength: 0)
}
.padding(SodaSpacing.s12)
.background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
}
struct AiCharacterCard_Previews: PreviewProvider {
static var previews: some View {
AiCharacterCard(
name: "AI 캐릭터",
description: "캐릭터 설명이 표시됩니다.",
profileImageUrl: nil,
chatCount: 128,
originalTitle: "원작"
)
.padding(SodaSpacing.s20)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,130 @@
import SwiftUI
struct CommunityPostCard: View {
let creatorNickname: String
let creatorProfileImageUrl: String?
let content: String
let imageUrl: String?
let price: Int?
let existOrdered: Bool
let createdAt: String?
let likeCount: Int?
let commentCount: Int?
var body: some View {
VStack(alignment: .leading, spacing: SodaSpacing.s12) {
header
Text(content)
.appFont(.body4)
.foregroundColor(.white)
.lineLimit(3)
.truncationMode(.tail)
if hasImage {
imageContent
}
footer
}
.padding(SodaSpacing.s12)
.background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
private var header: some View {
HStack(spacing: SodaSpacing.s8) {
DownsampledKFImage(url: URL(string: creatorProfileImageUrl ?? ""), size: CGSize(width: 32, height: 32))
.background(Color.gray800)
.clipShape(Circle())
Text(creatorNickname)
.appFont(.body5)
.foregroundColor(.white)
.lineLimit(1)
Spacer(minLength: 0)
}
}
private var imageContent: some View {
ZStack {
DownsampledKFImage(url: URL(string: imageUrl ?? ""), size: CGSize(width: 240, height: 150))
.background(Color.gray800)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s12, style: .continuous))
.blur(radius: isLocked ? 8 : 0)
if isLocked {
VStack(spacing: SodaSpacing.s6) {
Image("ic_new_community_lock")
.resizable()
.renderingMode(.template)
.foregroundColor(.white)
.frame(width: 24, height: 24)
if let price {
Text("\(price) can")
.appFont(.caption2)
.foregroundColor(Color.button)
.padding(.horizontal, SodaSpacing.s12)
.padding(.vertical, SodaSpacing.s6)
.overlay(
Capsule()
.stroke(Color.button, lineWidth: 1)
)
}
}
}
}
}
private var footer: some View {
HStack(spacing: SodaSpacing.s12) {
if let createdAt, !createdAt.isEmpty {
Text(createdAt)
.appFont(.caption2)
.foregroundColor(Color.gray500)
}
if let likeCount {
Text("Like \(likeCount)")
.appFont(.caption2)
.foregroundColor(Color.gray500)
}
if let commentCount {
Text("Comment \(commentCount)")
.appFont(.caption2)
.foregroundColor(Color.gray500)
}
}
}
private var hasImage: Bool {
!(imageUrl ?? "").isEmpty
}
private var isLocked: Bool {
(price ?? 0) > 0 && !existOrdered
}
}
struct CommunityPostCard_Previews: PreviewProvider {
static var previews: some View {
CommunityPostCard(
creatorNickname: "크리에이터",
creatorProfileImageUrl: nil,
content: "커뮤니티 본문입니다.",
imageUrl: nil,
price: nil,
existOrdered: false,
createdAt: "방금 전",
likeCount: 10,
commentCount: 2
)
.padding(SodaSpacing.s20)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,70 @@
import SwiftUI
struct CreatorProfileGridItem: Identifiable, Hashable {
let id: String
let imageUrl: String?
let name: String
let subtitle: String?
init(
id: String,
imageUrl: String?,
name: String,
subtitle: String? = nil
) {
self.id = id
self.imageUrl = imageUrl
self.name = name
self.subtitle = subtitle
}
}
struct CreatorProfileGrid: View {
let items: [CreatorProfileGridItem]
let columns: Int
let spacing: CGFloat
let action: (CreatorProfileGridItem) -> Void
init(
items: [CreatorProfileGridItem],
columns: Int = 3,
spacing: CGFloat = SodaSpacing.s16,
action: @escaping (CreatorProfileGridItem) -> Void = { _ in }
) {
self.items = items
self.columns = columns
self.spacing = spacing
self.action = action
}
var body: some View {
LazyVGrid(columns: gridColumns, alignment: .center, spacing: spacing) {
ForEach(items) { item in
CreatorProfileItem(
imageUrl: item.imageUrl,
name: item.name,
subtitle: item.subtitle
) {
action(item)
}
}
}
}
private var gridColumns: [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: spacing), count: columns)
}
}
struct CreatorProfileGrid_Previews: PreviewProvider {
static var previews: some View {
CreatorProfileGrid(items: [
CreatorProfileGridItem(id: "1", imageUrl: nil, name: "크리에이터 1"),
CreatorProfileGridItem(id: "2", imageUrl: nil, name: "크리에이터 2"),
CreatorProfileGridItem(id: "3", imageUrl: nil, name: "크리에이터 3")
])
.padding(SodaSpacing.s20)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,65 @@
import SwiftUI
struct CreatorProfileItem: View {
let imageUrl: String?
let name: String
let subtitle: String?
let action: (() -> Void)?
init(
imageUrl: String?,
name: String,
subtitle: String? = nil,
action: (() -> Void)? = nil
) {
self.imageUrl = imageUrl
self.name = name
self.subtitle = subtitle
self.action = action
}
var body: some View {
Button {
action?()
} label: {
VStack(alignment: .center, spacing: SodaSpacing.s8) {
profileImage
VStack(alignment: .center, spacing: 2) {
Text(name)
.appFont(.body4)
.foregroundColor(.white)
.lineLimit(1)
.truncationMode(.tail)
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.appFont(.caption2)
.foregroundColor(Color.gray500)
.lineLimit(1)
.truncationMode(.tail)
}
}
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.disabled(action == nil)
}
private var profileImage: some View {
DownsampledKFImage(url: URL(string: imageUrl ?? ""), size: CGSize(width: 72, height: 72))
.background(Color.gray800)
.clipShape(Circle())
}
}
struct CreatorProfileItem_Previews: PreviewProvider {
static var previews: some View {
CreatorProfileItem(imageUrl: nil, name: "크리에이터", subtitle: "방금 활동")
.frame(width: 96)
.padding(SodaSpacing.s20)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,81 @@
import SwiftUI
struct ExpandableTextView: View {
let text: String
let lineLimit: Int
let moreTitle: String
let collapseTitle: String
let font: SodaTypography
let foregroundColor: Color
@State private var isExpanded = false
@State private var fullTextHeight: CGFloat = 0
@State private var limitedTextHeight: CGFloat = 0
init(
text: String,
lineLimit: Int = 3,
moreTitle: String = I18n.HomeRecommendation.more,
collapseTitle: String = I18n.HomeRecommendation.collapse,
font: SodaTypography = .body3,
foregroundColor: Color = Color.gray500
) {
self.text = text
self.lineLimit = lineLimit
self.moreTitle = moreTitle
self.collapseTitle = collapseTitle
self.font = font
self.foregroundColor = foregroundColor
}
var body: some View {
VStack(alignment: .leading, spacing: SodaSpacing.s8) {
Text(text)
.appFont(font)
.foregroundColor(foregroundColor)
.lineLimit(isExpanded ? nil : lineLimit)
.background(measuringText(lineLimit: nil, height: $fullTextHeight))
.background(measuringText(lineLimit: lineLimit, height: $limitedTextHeight))
if shouldShowToggle {
Button {
isExpanded.toggle()
} label: {
Text(isExpanded ? collapseTitle : moreTitle)
.appFont(.body5)
.foregroundColor(.white)
}
.buttonStyle(.plain)
}
}
}
private var shouldShowToggle: Bool {
fullTextHeight > limitedTextHeight + 1
}
private func measuringText(lineLimit: Int?, height: Binding<CGFloat>) -> some View {
Text(text)
.appFont(font)
.lineLimit(lineLimit)
.fixedSize(horizontal: false, vertical: true)
.hidden()
.background(
GeometryReader { proxy in
Color.clear
.onAppear { height.wrappedValue = proxy.size.height }
.onChange(of: proxy.size.height) { newHeight in height.wrappedValue = newHeight }
.onChange(of: text) { _ in height.wrappedValue = proxy.size.height }
}
)
}
}
struct ExpandableTextView_Previews: PreviewProvider {
static var previews: some View {
ExpandableTextView(text: "긴 사업자 정보 텍스트가 들어가는 영역입니다. 세 줄을 넘어가면 더보기 버튼이 표시되고, 더보기 버튼을 누르면 전체 문구가 표시됩니다.")
.padding(SodaSpacing.s20)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}