feat(original): 작품별 상세 UI 변경
- 캐릭터 / 작품 정보 탭 추가 - 작품 정보 탭 구성 - 작품 소개 - 원작 보러 가기 - 상세 정보 - 작가 - 제작사 - 원작
This commit is contained in:
@@ -12,8 +12,6 @@ struct OriginalWorkDetailHeaderView: View {
|
||||
|
||||
let item: OriginalWorkDetailResponse
|
||||
|
||||
@State var isDescriptionExpanded = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if let imageUrl = item.imageUrl {
|
||||
@@ -74,33 +72,15 @@ struct OriginalWorkDetailHeaderView: View {
|
||||
}
|
||||
.padding(.top, 14)
|
||||
|
||||
Text(item.description)
|
||||
.font(.custom(Font.preRegular.rawValue, size: 14))
|
||||
.foregroundColor(Color(hex: "cfd8dc"))
|
||||
.lineLimit(!isDescriptionExpanded ? 2 : Int.max)
|
||||
.truncationMode(.tail)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 14)
|
||||
.onTapGesture {
|
||||
isDescriptionExpanded.toggle()
|
||||
}
|
||||
|
||||
Text("원작 보러가기")
|
||||
.font(.custom(Font.preBold.rawValue, size: 16))
|
||||
.foregroundColor(.button)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 15)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(lineWidth: 1)
|
||||
.foregroundColor(.button)
|
||||
}
|
||||
.padding(.top, 24)
|
||||
.onTapGesture {
|
||||
if let link = item.originalLink, let url = URL(string: link) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
item.tags
|
||||
.map { $0.hasPrefix("#") ? $0 : "#\($0)" }
|
||||
.joined(separator: " ")
|
||||
)
|
||||
.font(.custom(Font.preRegular.rawValue, size: 14))
|
||||
.foregroundColor(Color(hex: "3bb9f1"))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 14)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,7 +94,12 @@ struct OriginalWorkDetailHeaderView: View {
|
||||
category: "로맨스",
|
||||
isAdult: true,
|
||||
description: "작품설명입니다.보이스온의 오픈월드 캐릭터톡은 청소년 보호를 위해 본인인증한성인만 이용이 가능합니다.캐릭터톡 서비스를 이용하시려면 본인인증을 하고 이용해주세요.",
|
||||
originalWork: nil,
|
||||
originalLink: "https://apple.com",
|
||||
writer: nil,
|
||||
studio: nil,
|
||||
originalLinks: [],
|
||||
tags: [],
|
||||
characters: []
|
||||
)
|
||||
)
|
||||
|
||||
@@ -12,6 +12,11 @@ struct OriginalWorkDetailResponse: Decodable {
|
||||
let category: String
|
||||
let isAdult: Bool
|
||||
let description: String
|
||||
let originalWork: String?
|
||||
let originalLink: String?
|
||||
let writer: String?
|
||||
let studio: String?
|
||||
let originalLinks: [String]
|
||||
let tags: [String]
|
||||
let characters: [Character]
|
||||
}
|
||||
|
||||
@@ -14,9 +14,6 @@ struct OriginalWorkDetailView: View {
|
||||
|
||||
let originalId: Int
|
||||
|
||||
private let horizontalPadding: CGFloat = 12
|
||||
private let gridSpacing: CGFloat = 12
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
BaseView(isLoading: $viewModel.isLoading) {
|
||||
@@ -46,54 +43,46 @@ struct OriginalWorkDetailView: View {
|
||||
.frame(height: 56)
|
||||
|
||||
if let response = viewModel.response {
|
||||
GeometryReader { geo in
|
||||
let totalSpacing: CGFloat = gridSpacing * 2
|
||||
let width = (geo.size.width - (horizontalPadding * 2) - totalSpacing) / 3
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
VStack(spacing: 0) {
|
||||
OriginalWorkDetailHeaderView(item: response)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 24)
|
||||
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
VStack(spacing: 0) {
|
||||
OriginalWorkDetailHeaderView(item: response)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 24)
|
||||
|
||||
LazyVGrid(
|
||||
columns: Array(
|
||||
repeating: GridItem(
|
||||
.flexible(),
|
||||
spacing: gridSpacing,
|
||||
alignment: .topLeading
|
||||
),
|
||||
count: 3
|
||||
),
|
||||
alignment: .leading,
|
||||
spacing: gridSpacing
|
||||
HStack(spacing: 0) {
|
||||
SeriesDetailTabView(
|
||||
title: "캐릭터",
|
||||
width: screenSize().width / 2,
|
||||
isSelected: viewModel.currentTab == .character
|
||||
) {
|
||||
ForEach(viewModel.characters.indices, id: \.self) { idx in
|
||||
let item = viewModel.characters[idx]
|
||||
|
||||
NavigationLink(value: item.characterId) {
|
||||
CharacterItemView(
|
||||
character: item,
|
||||
size: width,
|
||||
rank: 0,
|
||||
isShowRank: false
|
||||
)
|
||||
.onAppear { viewModel.loadMoreIfNeeded(currentIndex: idx) }
|
||||
}
|
||||
if viewModel.currentTab != .character {
|
||||
viewModel.currentTab = .character
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
|
||||
if viewModel.isLoadingMore {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
.padding(.vertical, 16)
|
||||
Spacer()
|
||||
SeriesDetailTabView(
|
||||
title: "작품정보",
|
||||
width: screenSize().width / 2,
|
||||
isSelected: viewModel.currentTab == .info
|
||||
) {
|
||||
if viewModel.currentTab != .info {
|
||||
viewModel.currentTab = .info
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color.black)
|
||||
|
||||
Rectangle()
|
||||
.foregroundColor(Color.gray90.opacity(0.5))
|
||||
.frame(height: 1)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
switch(viewModel.currentTab) {
|
||||
case .info:
|
||||
OriginalWorkInfoView(response: response)
|
||||
default:
|
||||
OriginalWorkCharacterView(characters: viewModel.characters)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,6 +101,170 @@ struct OriginalWorkDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct OriginalWorkCharacterView: View {
|
||||
|
||||
private let horizontalPadding: CGFloat = 12
|
||||
private let gridSpacing: CGFloat = 12
|
||||
|
||||
let characters: [Character]
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
let totalSpacing: CGFloat = gridSpacing * 2
|
||||
let width = (screenSize().width - (horizontalPadding * 2) - totalSpacing) / 3
|
||||
|
||||
LazyVGrid(
|
||||
columns: Array(
|
||||
repeating: GridItem(
|
||||
.flexible(),
|
||||
spacing: gridSpacing,
|
||||
alignment: .topLeading
|
||||
),
|
||||
count: 3
|
||||
),
|
||||
alignment: .leading,
|
||||
spacing: gridSpacing
|
||||
) {
|
||||
ForEach(characters.indices, id: \.self) { idx in
|
||||
let item = characters[idx]
|
||||
|
||||
NavigationLink(value: item.characterId) {
|
||||
CharacterItemView(
|
||||
character: item,
|
||||
size: width,
|
||||
rank: 0,
|
||||
isShowRank: false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
}
|
||||
.padding(.top, 24)
|
||||
.background(Color.black)
|
||||
}
|
||||
}
|
||||
|
||||
struct OriginalWorkInfoView: View {
|
||||
|
||||
let response: OriginalWorkDetailResponse
|
||||
|
||||
@State private var isExpandDesc = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
VStack(spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("작품 소개")
|
||||
.font(.custom(Font.preBold.rawValue, size: 16))
|
||||
.foregroundColor(.white)
|
||||
|
||||
Text(response.description)
|
||||
.font(.custom(Font.preRegular.rawValue, size: 14))
|
||||
.foregroundColor(Color(hex: "B0BEC5"))
|
||||
.lineLimit(isExpandDesc ? Int.max : 3)
|
||||
.truncationMode(.tail)
|
||||
.onTapGesture {
|
||||
isExpandDesc.toggle()
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(hex: "263238"))
|
||||
.cornerRadius(16)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("원작 보러 가기")
|
||||
.font(.custom(Font.preBold.rawValue, size: 16))
|
||||
.foregroundColor(Color(hex: "B0BEC5"))
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(0..<response.originalLinks.count, id: \.self) {
|
||||
let link = response.originalLinks[$0]
|
||||
|
||||
Text(link)
|
||||
.font(.custom(Font.preRegular.rawValue, size: 14))
|
||||
.foregroundColor(.white)
|
||||
.onTapGesture {
|
||||
if let url = URL(string: link) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(hex: "263238"))
|
||||
.cornerRadius(16)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("상세 정보")
|
||||
.font(.custom(Font.preBold.rawValue, size: 16))
|
||||
.foregroundColor(.white)
|
||||
|
||||
HStack(spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let _ = response.writer {
|
||||
Text("작가")
|
||||
.font(.custom(Font.preRegular.rawValue, size: 14))
|
||||
.foregroundColor(Color(hex: "B0BEC5"))
|
||||
}
|
||||
|
||||
if let _ = response.studio {
|
||||
Text("제작사")
|
||||
.font(.custom(Font.preRegular.rawValue, size: 14))
|
||||
.foregroundColor(Color(hex: "B0BEC5"))
|
||||
}
|
||||
|
||||
if let _ = response.originalWork {
|
||||
Text("원작")
|
||||
.font(.custom(Font.preRegular.rawValue, size: 14))
|
||||
.foregroundColor(Color(hex: "B0BEC5"))
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let writer = response.writer {
|
||||
Text(writer)
|
||||
.font(.custom(Font.preRegular.rawValue, size: 14))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
if let studio = response.studio {
|
||||
Text(studio)
|
||||
.font(.custom(Font.preRegular.rawValue, size: 14))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
if let originalWork = response.originalWork {
|
||||
Text(originalWork)
|
||||
.font(.custom(Font.preRegular.rawValue, size: 14))
|
||||
.foregroundColor(.white)
|
||||
.underline(response.originalLink != nil ? true : false)
|
||||
.onTapGesture {
|
||||
if let link = response.originalLink, let url = URL(string: link) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(hex: "263238"))
|
||||
.cornerRadius(16)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.black)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
OriginalWorkDetailView(originalId: 0)
|
||||
}
|
||||
|
||||
@@ -10,10 +10,14 @@ import Combine
|
||||
import Moya
|
||||
|
||||
final class OriginalWorkDetailViewModel: ObservableObject {
|
||||
enum CurrentTab: String {
|
||||
case character, info
|
||||
}
|
||||
|
||||
@Published var currentTab: CurrentTab = .character
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage = ""
|
||||
@Published var isShowPopup = false
|
||||
@Published var isLoadingMore: Bool = false
|
||||
|
||||
@Published private(set) var characters: [Character] = []
|
||||
@Published private(set) var totalCount: Int = 0
|
||||
@@ -21,8 +25,6 @@ final class OriginalWorkDetailViewModel: ObservableObject {
|
||||
|
||||
private let repository = OriginalWorkRepository()
|
||||
private var subscription = Set<AnyCancellable>()
|
||||
private var currentPage: Int = 0
|
||||
private var hasMorePages: Bool = true
|
||||
|
||||
var originalId: Int = 0 {
|
||||
didSet {
|
||||
@@ -31,14 +33,6 @@ final class OriginalWorkDetailViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
// MARK: - API
|
||||
func loadMoreIfNeeded(currentIndex: Int) {
|
||||
guard hasMorePages,
|
||||
!isLoading,
|
||||
!isLoadingMore,
|
||||
currentIndex >= characters.count - 3 else { return }
|
||||
fetchCharacters()
|
||||
}
|
||||
|
||||
private func fetchDetail() {
|
||||
isLoading = true
|
||||
|
||||
@@ -80,53 +74,4 @@ final class OriginalWorkDetailViewModel: ObservableObject {
|
||||
}
|
||||
.store(in: &subscription)
|
||||
}
|
||||
|
||||
private func fetchCharacters() {
|
||||
currentPage += 1
|
||||
isLoadingMore = true
|
||||
|
||||
repository.getOriginalWorkCharacters(id: originalId, page: currentPage)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] completion in
|
||||
switch completion {
|
||||
case .finished:
|
||||
DEBUG_LOG("finish")
|
||||
case .failure(let error):
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self?.isLoadingMore = false
|
||||
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
|
||||
self?.isShowPopup = true
|
||||
}
|
||||
} receiveValue: { [weak self] response in
|
||||
guard let self = self else { return }
|
||||
do {
|
||||
let jsonDecoder = JSONDecoder()
|
||||
let decoded = try jsonDecoder.decode(ApiResponse<OriginalWorkCharactersPageResponse>.self, from: response.data)
|
||||
if let data = decoded.data, decoded.success {
|
||||
self.totalCount = data.totalCount
|
||||
|
||||
if !data.content.isEmpty {
|
||||
self.characters.append(contentsOf: data.content)
|
||||
} else {
|
||||
hasMorePages = false
|
||||
}
|
||||
|
||||
self.isLoadingMore = false
|
||||
} else {
|
||||
if let message = decoded.message {
|
||||
self.errorMessage = message
|
||||
} else {
|
||||
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
|
||||
}
|
||||
self.isShowPopup = true
|
||||
self.isLoadingMore = false
|
||||
}
|
||||
} catch {
|
||||
self.isLoading = false
|
||||
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
|
||||
self.isShowPopup = true
|
||||
}
|
||||
}
|
||||
.store(in: &subscription)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import Moya
|
||||
enum OriginalWorkApi {
|
||||
case getOriginalWorks(page: Int, size: Int)
|
||||
case getOriginalDetail(id: Int)
|
||||
case getOriginalWorkCharacters(id: Int, page: Int, size: Int)
|
||||
}
|
||||
|
||||
extension OriginalWorkApi: TargetType {
|
||||
@@ -24,9 +23,6 @@ extension OriginalWorkApi: TargetType {
|
||||
|
||||
case .getOriginalDetail(let id):
|
||||
return "/api/chat/original/\(id)"
|
||||
|
||||
case .getOriginalWorkCharacters(let id, _, _):
|
||||
return "/api/chat/original/\(id)/characters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,12 +40,6 @@ extension OriginalWorkApi: TargetType {
|
||||
|
||||
case .getOriginalDetail:
|
||||
return .requestPlain
|
||||
|
||||
case .getOriginalWorkCharacters(_, let page, let size):
|
||||
return .requestParameters(
|
||||
parameters: ["page": page, "size": size],
|
||||
encoding: URLEncoding.queryString
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,8 +20,4 @@ class OriginalWorkRepository {
|
||||
func getOriginalDetail(id: Int) -> AnyPublisher<Response, MoyaError> {
|
||||
return api.requestPublisher(.getOriginalDetail(id: id))
|
||||
}
|
||||
|
||||
func getOriginalWorkCharacters(id: Int, page: Int) -> AnyPublisher<Response, MoyaError> {
|
||||
return api.requestPublisher(.getOriginalWorkCharacters(id: id, page: page, size: 20))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import MediaPlayer
|
||||
import Combine
|
||||
|
||||
import Kingfisher
|
||||
import SwiftUICore
|
||||
import SwiftUI
|
||||
|
||||
final class ContentPlayerPlayManager: NSObject, ObservableObject {
|
||||
enum LoopState {
|
||||
|
||||
Reference in New Issue
Block a user