import SwiftUI struct AudioContentListRowItem: Identifiable { let id: Int let title: String let subtitle: String? let imageUrl: String? let price: Int let isAdult: Bool let isPointAvailable: Bool let isFirstContent: Bool let isOriginalSeries: Bool let isOwned: Bool let isRented: Bool } extension AudioContentListRowItem { init(audioContent: CreatorChannelAudioContentResponse, subtitle: String?) { self.init( id: audioContent.audioContentId, title: audioContent.title, subtitle: subtitle, imageUrl: audioContent.imageUrl, price: audioContent.price, isAdult: audioContent.isAdult, isPointAvailable: audioContent.isPointAvailable, isFirstContent: audioContent.isFirstContent, isOriginalSeries: audioContent.isOriginalSeries == true, isOwned: audioContent.isOwned, isRented: audioContent.isRented ) } } enum AudioContentListRowStyle { case compact(width: CGFloat?, showsAdultTag: Bool = false) case purchaseState static func compactWidth(screenWidth: CGFloat = UIScreen.main.bounds.width) -> CGFloat { let baselineScreenWidth: CGFloat = 402 let baselineRowWidth: CGFloat = 346 return baselineRowWidth * min(screenWidth, baselineScreenWidth) / baselineScreenWidth } } struct AudioContentListRow: View { let item: AudioContentListRowItem let style: AudioContentListRowStyle let action: () -> Void init( item: AudioContentListRowItem, style: AudioContentListRowStyle, action: @escaping () -> Void = {} ) { self.item = item self.style = style self.action = action } var body: some View { Button { action() } label: { HStack(alignment: .center, spacing: SodaSpacing.s14) { thumbnail titleArea .frame(maxWidth: .infinity, alignment: .leading) if showsTrailingState { trailingState } } .padding(.horizontal, horizontalPadding) .padding(.vertical, verticalPadding) .frame(width: rowWidth, alignment: .leading) .frame(maxWidth: maxRowWidth, alignment: .leading) .contentShape(Rectangle()) } .buttonStyle(.plain) } private var titleArea: some View { VStack(alignment: .leading, spacing: titleSpacing) { Text(item.title) .appFont(size: 16, weight: .bold) .foregroundColor(.white) .lineLimit(titleLineLimit) .truncationMode(.tail) if let subtitle = item.subtitle, !subtitle.isEmpty { Text(subtitle) .appFont(size: 14, weight: .medium) .foregroundColor(Color.gray500) .lineLimit(1) .truncationMode(.tail) } } } private var thumbnail: some View { ZStack(alignment: .topLeading) { DownsampledKFImage( url: item.imageUrl.flatMap(URL.init(string:)), size: CGSize(width: 88, height: 88) ) .background(Color.gray800) VStack(alignment: .leading, spacing: 0) { topTagList Spacer(minLength: 0) bottomTagList } } .frame(width: 88, height: 88) .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) } @ViewBuilder private var topTagList: some View { HStack(spacing: 0) { if item.isOriginalSeries { CreatorChannelAudioImageTag(imageName: "ic_content_tag_original") } if item.isFirstContent { CreatorChannelAudioFirstTag() } if showsAdultTag { Spacer() if item.isAdult { CreatorChannelAudioAdultTag() .padding(.top, 6) .padding(.trailing, 6) } } } } @ViewBuilder private var bottomTagList: some View { HStack(spacing: 0) { if item.isPointAvailable { CreatorChannelAudioImageTag(imageName: "ic_content_tag_point") } if item.price == 0 { CreatorChannelAudioFreeTag() } } } @ViewBuilder private var trailingState: some View { if item.isOwned { playbackState(I18n.Content.Status.owned) } else if item.isRented { playbackState(I18n.Content.Status.rented) } else if item.price == 0 { playbackState("") } else { HStack(spacing: 2) { Image("ic_bar_cash") .resizable() .scaledToFit() .frame(width: 18, height: 18) Text(item.price.comma()) .appFont(size: 14, weight: .regular) .foregroundColor(.white) .lineLimit(1) } } } private func playbackState(_ title: String) -> some View { VStack(spacing: SodaSpacing.s4) { ZStack { Color.white Image("ic_new_player_play") .resizable() .scaledToFit() .frame(width: 16, height: 16) } .frame(width: 28, height: 28) .clipShape(Circle()) if !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { Text(title) .appFont(size: 12, weight: .medium) .foregroundColor(Color.gray400) .lineLimit(1) } } } private var rowWidth: CGFloat? { if case let .compact(width, _) = style { return width } return nil } private var maxRowWidth: CGFloat? { switch style { case .compact: return nil case .purchaseState: return .infinity } } private var horizontalPadding: CGFloat { switch style { case .compact: return 0 case .purchaseState: return SodaSpacing.s20 } } private var verticalPadding: CGFloat { switch style { case .compact: return 0 case .purchaseState: return SodaSpacing.s8 } } private var titleSpacing: CGFloat { switch style { case .compact: return 2 case .purchaseState: return SodaSpacing.s6 } } private var titleLineLimit: Int { switch style { case .compact: return 1 case .purchaseState: return 2 } } private var showsTrailingState: Bool { if case .purchaseState = style { return true } return false } private var showsAdultTag: Bool { switch style { case let .compact(_, showsAdultTag): return showsAdultTag case .purchaseState: return true } } } struct CreatorChannelAudioImageTag: View { let imageName: String var body: some View { Image(imageName) .resizable() .scaledToFit() .frame(width: 24, height: 24) } } struct CreatorChannelAudioFirstTag: View { var body: some View { ZStack { Color(hex: "FF34B8") Image("ic_content_tag_first_star") .resizable() .scaledToFit() .padding(SodaSpacing.s4) } .frame(width: 24, height: 24) } } struct CreatorChannelAudioAdultTag: View { var body: some View { ZStack { Color(hex: "FF4C3C") Image("ic_new_shield_small") .resizable() .scaledToFit() .padding(2) } .frame(width: 18, height: 18) .cornerRadius(4) } } struct CreatorChannelAudioFreeTag: View { var body: some View { Text("무료") .appFont(size: 14, weight: .semibold) .foregroundColor(.white) .lineLimit(1) .padding(SodaSpacing.s4) .frame(height: 24) .background(Color.soda900) } } struct AudioContentListRow_Previews: PreviewProvider { static var previews: some View { VStack(spacing: SodaSpacing.s8) { AudioContentListRow( item: AudioContentListRowItem( id: 1, title: "콘텐츠 이름이 한 줄 콘텐츠 이름이 한 줄 콘텐츠 이름이 한 줄", subtitle: "3:00 • 시리즈 이름", imageUrl: "https://picsum.photos/300/300", price: 0, isAdult: false, isPointAvailable: true, isFirstContent: true, isOriginalSeries: true, isOwned: false, isRented: false ), style: .compact(width: 346) ) AudioContentListRow( item: AudioContentListRowItem( id: 2, title: "라이브 다시듣기 제목이 두 줄까지 표시됩니다", subtitle: "1:43:25", imageUrl: "https://picsum.photos/300/300", price: 3000, isAdult: true, isPointAvailable: true, isFirstContent: true, isOriginalSeries: true, isOwned: false, isRented: false ), style: .purchaseState ) } .background(Color.black) .previewLayout(.sizeThatFits) } }