feat(original): 작품별 상세 UI 변경
- 캐릭터 / 작품 정보 탭 추가 - 작품 정보 탭 구성 - 작품 소개 - 원작 보러 가기 - 상세 정보 - 작가 - 제작사 - 원작
This commit is contained in:
@@ -12,8 +12,6 @@ struct OriginalWorkDetailHeaderView: View {
|
|||||||
|
|
||||||
let item: OriginalWorkDetailResponse
|
let item: OriginalWorkDetailResponse
|
||||||
|
|
||||||
@State var isDescriptionExpanded = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if let imageUrl = item.imageUrl {
|
if let imageUrl = item.imageUrl {
|
||||||
@@ -74,33 +72,15 @@ struct OriginalWorkDetailHeaderView: View {
|
|||||||
}
|
}
|
||||||
.padding(.top, 14)
|
.padding(.top, 14)
|
||||||
|
|
||||||
Text(item.description)
|
Text(
|
||||||
.font(.custom(Font.preRegular.rawValue, size: 14))
|
item.tags
|
||||||
.foregroundColor(Color(hex: "cfd8dc"))
|
.map { $0.hasPrefix("#") ? $0 : "#\($0)" }
|
||||||
.lineLimit(!isDescriptionExpanded ? 2 : Int.max)
|
.joined(separator: " ")
|
||||||
.truncationMode(.tail)
|
)
|
||||||
.frame(maxWidth: .infinity)
|
.font(.custom(Font.preRegular.rawValue, size: 14))
|
||||||
.padding(.top, 14)
|
.foregroundColor(Color(hex: "3bb9f1"))
|
||||||
.onTapGesture {
|
.frame(maxWidth: .infinity)
|
||||||
isDescriptionExpanded.toggle()
|
.padding(.top, 14)
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,7 +94,12 @@ struct OriginalWorkDetailHeaderView: View {
|
|||||||
category: "로맨스",
|
category: "로맨스",
|
||||||
isAdult: true,
|
isAdult: true,
|
||||||
description: "작품설명입니다.보이스온의 오픈월드 캐릭터톡은 청소년 보호를 위해 본인인증한성인만 이용이 가능합니다.캐릭터톡 서비스를 이용하시려면 본인인증을 하고 이용해주세요.",
|
description: "작품설명입니다.보이스온의 오픈월드 캐릭터톡은 청소년 보호를 위해 본인인증한성인만 이용이 가능합니다.캐릭터톡 서비스를 이용하시려면 본인인증을 하고 이용해주세요.",
|
||||||
|
originalWork: nil,
|
||||||
originalLink: "https://apple.com",
|
originalLink: "https://apple.com",
|
||||||
|
writer: nil,
|
||||||
|
studio: nil,
|
||||||
|
originalLinks: [],
|
||||||
|
tags: [],
|
||||||
characters: []
|
characters: []
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ struct OriginalWorkDetailResponse: Decodable {
|
|||||||
let category: String
|
let category: String
|
||||||
let isAdult: Bool
|
let isAdult: Bool
|
||||||
let description: String
|
let description: String
|
||||||
|
let originalWork: String?
|
||||||
let originalLink: String?
|
let originalLink: String?
|
||||||
|
let writer: String?
|
||||||
|
let studio: String?
|
||||||
|
let originalLinks: [String]
|
||||||
|
let tags: [String]
|
||||||
let characters: [Character]
|
let characters: [Character]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ struct OriginalWorkDetailView: View {
|
|||||||
|
|
||||||
let originalId: Int
|
let originalId: Int
|
||||||
|
|
||||||
private let horizontalPadding: CGFloat = 12
|
|
||||||
private let gridSpacing: CGFloat = 12
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
BaseView(isLoading: $viewModel.isLoading) {
|
BaseView(isLoading: $viewModel.isLoading) {
|
||||||
@@ -46,54 +43,46 @@ struct OriginalWorkDetailView: View {
|
|||||||
.frame(height: 56)
|
.frame(height: 56)
|
||||||
|
|
||||||
if let response = viewModel.response {
|
if let response = viewModel.response {
|
||||||
GeometryReader { geo in
|
ScrollView(.vertical, showsIndicators: false) {
|
||||||
let totalSpacing: CGFloat = gridSpacing * 2
|
VStack(spacing: 0) {
|
||||||
let width = (geo.size.width - (horizontalPadding * 2) - totalSpacing) / 3
|
OriginalWorkDetailHeaderView(item: response)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
|
||||||
ScrollView(.vertical, showsIndicators: false) {
|
HStack(spacing: 0) {
|
||||||
VStack(spacing: 0) {
|
SeriesDetailTabView(
|
||||||
OriginalWorkDetailHeaderView(item: response)
|
title: "캐릭터",
|
||||||
.padding(.horizontal, 24)
|
width: screenSize().width / 2,
|
||||||
.padding(.bottom, 24)
|
isSelected: viewModel.currentTab == .character
|
||||||
|
|
||||||
LazyVGrid(
|
|
||||||
columns: Array(
|
|
||||||
repeating: GridItem(
|
|
||||||
.flexible(),
|
|
||||||
spacing: gridSpacing,
|
|
||||||
alignment: .topLeading
|
|
||||||
),
|
|
||||||
count: 3
|
|
||||||
),
|
|
||||||
alignment: .leading,
|
|
||||||
spacing: gridSpacing
|
|
||||||
) {
|
) {
|
||||||
ForEach(viewModel.characters.indices, id: \.self) { idx in
|
if viewModel.currentTab != .character {
|
||||||
let item = viewModel.characters[idx]
|
viewModel.currentTab = .character
|
||||||
|
|
||||||
NavigationLink(value: item.characterId) {
|
|
||||||
CharacterItemView(
|
|
||||||
character: item,
|
|
||||||
size: width,
|
|
||||||
rank: 0,
|
|
||||||
isShowRank: false
|
|
||||||
)
|
|
||||||
.onAppear { viewModel.loadMoreIfNeeded(currentIndex: idx) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, horizontalPadding)
|
|
||||||
|
|
||||||
if viewModel.isLoadingMore {
|
SeriesDetailTabView(
|
||||||
HStack {
|
title: "작품정보",
|
||||||
Spacer()
|
width: screenSize().width / 2,
|
||||||
ProgressView()
|
isSelected: viewModel.currentTab == .info
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
) {
|
||||||
.padding(.vertical, 16)
|
if viewModel.currentTab != .info {
|
||||||
Spacer()
|
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 {
|
#Preview {
|
||||||
OriginalWorkDetailView(originalId: 0)
|
OriginalWorkDetailView(originalId: 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,14 @@ import Combine
|
|||||||
import Moya
|
import Moya
|
||||||
|
|
||||||
final class OriginalWorkDetailViewModel: ObservableObject {
|
final class OriginalWorkDetailViewModel: ObservableObject {
|
||||||
|
enum CurrentTab: String {
|
||||||
|
case character, info
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var currentTab: CurrentTab = .character
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
@Published var errorMessage = ""
|
@Published var errorMessage = ""
|
||||||
@Published var isShowPopup = false
|
@Published var isShowPopup = false
|
||||||
@Published var isLoadingMore: Bool = false
|
|
||||||
|
|
||||||
@Published private(set) var characters: [Character] = []
|
@Published private(set) var characters: [Character] = []
|
||||||
@Published private(set) var totalCount: Int = 0
|
@Published private(set) var totalCount: Int = 0
|
||||||
@@ -21,8 +25,6 @@ final class OriginalWorkDetailViewModel: ObservableObject {
|
|||||||
|
|
||||||
private let repository = OriginalWorkRepository()
|
private let repository = OriginalWorkRepository()
|
||||||
private var subscription = Set<AnyCancellable>()
|
private var subscription = Set<AnyCancellable>()
|
||||||
private var currentPage: Int = 0
|
|
||||||
private var hasMorePages: Bool = true
|
|
||||||
|
|
||||||
var originalId: Int = 0 {
|
var originalId: Int = 0 {
|
||||||
didSet {
|
didSet {
|
||||||
@@ -31,14 +33,6 @@ final class OriginalWorkDetailViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - API
|
// MARK: - API
|
||||||
func loadMoreIfNeeded(currentIndex: Int) {
|
|
||||||
guard hasMorePages,
|
|
||||||
!isLoading,
|
|
||||||
!isLoadingMore,
|
|
||||||
currentIndex >= characters.count - 3 else { return }
|
|
||||||
fetchCharacters()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func fetchDetail() {
|
private func fetchDetail() {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
|
|
||||||
@@ -80,53 +74,4 @@ final class OriginalWorkDetailViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
.store(in: &subscription)
|
.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 {
|
enum OriginalWorkApi {
|
||||||
case getOriginalWorks(page: Int, size: Int)
|
case getOriginalWorks(page: Int, size: Int)
|
||||||
case getOriginalDetail(id: Int)
|
case getOriginalDetail(id: Int)
|
||||||
case getOriginalWorkCharacters(id: Int, page: Int, size: Int)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension OriginalWorkApi: TargetType {
|
extension OriginalWorkApi: TargetType {
|
||||||
@@ -24,9 +23,6 @@ extension OriginalWorkApi: TargetType {
|
|||||||
|
|
||||||
case .getOriginalDetail(let id):
|
case .getOriginalDetail(let id):
|
||||||
return "/api/chat/original/\(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:
|
case .getOriginalDetail:
|
||||||
return .requestPlain
|
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> {
|
func getOriginalDetail(id: Int) -> AnyPublisher<Response, MoyaError> {
|
||||||
return api.requestPublisher(.getOriginalDetail(id: id))
|
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 Combine
|
||||||
|
|
||||||
import Kingfisher
|
import Kingfisher
|
||||||
import SwiftUICore
|
import SwiftUI
|
||||||
|
|
||||||
final class ContentPlayerPlayManager: NSObject, ObservableObject {
|
final class ContentPlayerPlayManager: NSObject, ObservableObject {
|
||||||
enum LoopState {
|
enum LoopState {
|
||||||
|
|||||||
Reference in New Issue
Block a user