feat(character-detail): 상세 화면 도입 및 네비게이션/API 연동
This commit is contained in:
@@ -161,4 +161,6 @@ enum AppStep {
|
||||
case pointStatus(refresh: () -> Void)
|
||||
|
||||
case audition
|
||||
|
||||
case characterDetail(characterId: Int)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import Moya
|
||||
|
||||
enum CharacterApi {
|
||||
case getCharacterHome
|
||||
case getCharacterDetail(characterId: Int)
|
||||
}
|
||||
|
||||
extension CharacterApi: TargetType {
|
||||
@@ -19,6 +20,9 @@ extension CharacterApi: TargetType {
|
||||
switch self {
|
||||
case .getCharacterHome:
|
||||
return "/api/chat/character/main"
|
||||
|
||||
case .getCharacterDetail(let characterId):
|
||||
return "/api/chat/character/\(characterId)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ struct CharacterView: View {
|
||||
// 배너
|
||||
if !viewModel.banners.isEmpty {
|
||||
AutoSlideCharacterBannerView(items: viewModel.banners) { banner in
|
||||
DEBUG_LOG("Banner tapped: \(banner.characterId)")
|
||||
AppState.shared.setAppStep(step: .characterDetail(characterId: banner.characterId))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ struct CharacterView: View {
|
||||
titleCount: viewModel.recentCharacters.count,
|
||||
items: viewModel.recentCharacters
|
||||
) { ch in
|
||||
DEBUG_LOG("Recent tapped: \(ch.characterId)")
|
||||
AppState.shared.setAppStep(step: .characterDetail(characterId: ch.characterId))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ struct CharacterView: View {
|
||||
title: "신규 캐릭터",
|
||||
items: viewModel.newCharacters
|
||||
) { ch in
|
||||
DEBUG_LOG("New tapped: \(ch.characterId)")
|
||||
AppState.shared.setAppStep(step: .characterDetail(characterId: ch.characterId))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ struct CharacterView: View {
|
||||
title: section.title,
|
||||
items: section.characters
|
||||
) { ch in
|
||||
DEBUG_LOG("Curation tapped: \\(ch.characterId)")
|
||||
AppState.shared.setAppStep(step: .characterDetail(characterId: ch.characterId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// CharacterCommentResponse.swift
|
||||
// SodaLive
|
||||
//
|
||||
// Created by klaus on 9/1/25.
|
||||
//
|
||||
|
||||
struct CharacterCommentResponse: Decodable {
|
||||
let commentId: Int
|
||||
let memberId: Int
|
||||
let memberProfileImage: String
|
||||
let memberNickname: String
|
||||
let createdAt: Int64
|
||||
let replyCount: Int
|
||||
let comment: String
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// CharacterDetailRepository.swift
|
||||
// SodaLive
|
||||
//
|
||||
// Created by klaus on 9/1/25.
|
||||
//
|
||||
import Foundation
|
||||
import CombineMoya
|
||||
import Combine
|
||||
import Moya
|
||||
|
||||
class CharacterDetailRepository {
|
||||
private let characterApi = MoyaProvider<CharacterApi>()
|
||||
private let talkApi = MoyaProvider<TalkApi>()
|
||||
|
||||
func getCharacterDetail(characterId: Int) -> AnyPublisher<Response, MoyaError> {
|
||||
return characterApi.requestPublisher(.getCharacterDetail(characterId: characterId))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// CharacterDetailResponse.swift
|
||||
// SodaLive
|
||||
//
|
||||
// Created by klaus on 9/1/25.
|
||||
//
|
||||
|
||||
struct CharacterDetailResponse: Decodable {
|
||||
let characterId: Int
|
||||
let name: String
|
||||
let description: String
|
||||
let mbti: String?
|
||||
let imageUrl: String
|
||||
let personalities: CharacterPersonalityResponse?
|
||||
let backgrounds: CharacterBackgroundResponse?
|
||||
let tags: String
|
||||
let originalTitle: String?
|
||||
let originalLink: String?
|
||||
let characterType: CharacterType
|
||||
let others: [OtherCharacter]
|
||||
let latestComment: CharacterCommentResponse?
|
||||
let totalComments: Int
|
||||
}
|
||||
|
||||
enum CharacterType: String, Decodable {
|
||||
case Clone, Character
|
||||
}
|
||||
|
||||
struct OtherCharacter: Decodable {
|
||||
let characterId: Int
|
||||
let name: String
|
||||
let imageUrl: String
|
||||
let tags: String
|
||||
}
|
||||
|
||||
struct CharacterPersonalityResponse: Decodable {
|
||||
let trait: String
|
||||
let description: String
|
||||
}
|
||||
|
||||
struct CharacterBackgroundResponse: Decodable {
|
||||
let topic: String
|
||||
let description: String
|
||||
}
|
||||
@@ -8,11 +8,396 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CharacterDetailView: View {
|
||||
|
||||
let characterId: Int
|
||||
@StateObject var viewModel = CharacterDetailViewModel()
|
||||
|
||||
@State private var selectedTab: InnerTab = .detail
|
||||
@State private var showMoreWorldView = false
|
||||
@State private var showMorePersonality = false
|
||||
|
||||
private enum InnerTab: Int, CaseIterable {
|
||||
case detail = 0
|
||||
case gallery = 1
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .detail: return "상세"
|
||||
case .gallery: return "갤러리"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
|
||||
BaseView(isLoading: $viewModel.isLoading) {
|
||||
VStack(spacing: 0) {
|
||||
DetailNavigationBar(title: viewModel.characterDetail?.name ?? "캐릭터 정보")
|
||||
|
||||
tabBar
|
||||
|
||||
ZStack(alignment: .bottom) {
|
||||
if selectedTab == .detail {
|
||||
// 상세 탭
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// 캐릭터 이미지 섹션
|
||||
characterImageSection
|
||||
.id("top") // 스크롤 최상단 식별자
|
||||
|
||||
// 프로필 정보 섹션
|
||||
profileSection
|
||||
|
||||
// 세계관 및 작품 소개
|
||||
if let backgrounds = viewModel.characterDetail?.backgrounds {
|
||||
worldViewSection(backgrounds: backgrounds)
|
||||
}
|
||||
|
||||
// 원작 섹션
|
||||
if let originalTitle = viewModel.characterDetail?.originalTitle,
|
||||
let originalLink = viewModel.characterDetail?.originalLink {
|
||||
originalWorkSection(title: originalTitle, link: originalLink)
|
||||
}
|
||||
|
||||
// 성격 및 특징 섹션
|
||||
if let personalities = viewModel.characterDetail?.personalities {
|
||||
personalitySection(personalities: personalities)
|
||||
}
|
||||
|
||||
// 장르의 다른 캐릭터 섹션
|
||||
if let others = viewModel.characterDetail?.others, !others.isEmpty {
|
||||
VStack(spacing: 16) {
|
||||
HStack {
|
||||
Text("장르의 다른 캐릭터")
|
||||
.font(.custom(Font.preBold.rawValue, size: 26))
|
||||
.foregroundColor(.white)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 16) {
|
||||
ForEach(others, id: \.characterId) { otherCharacter in
|
||||
CharacterItemView(
|
||||
character: Character(
|
||||
characterId: otherCharacter.characterId,
|
||||
name: otherCharacter.name,
|
||||
description: otherCharacter.tags,
|
||||
imageUrl: otherCharacter.imageUrl
|
||||
),
|
||||
size: screenSize().width * 0.42
|
||||
)
|
||||
.onTapGesture {
|
||||
// 캐릭터 변경 후 스크롤을 최상단으로 이동
|
||||
viewModel.characterId = otherCharacter.characterId
|
||||
|
||||
// 스크롤을 최상단으로 이동
|
||||
proxy.scrollTo("top")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 24)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
|
||||
Spacer(minLength: 64)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 갤러리 탭
|
||||
CharacterDetailGalleryView()
|
||||
}
|
||||
|
||||
// 대화하기 버튼
|
||||
if selectedTab == .detail {
|
||||
chatButton
|
||||
}
|
||||
}
|
||||
}
|
||||
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) {
|
||||
GeometryReader { geo in
|
||||
HStack {
|
||||
Spacer()
|
||||
Text(viewModel.errorMessage)
|
||||
.padding(.vertical, 13.3)
|
||||
.frame(alignment: .center)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 33.3)
|
||||
.font(.custom(Font.medium.rawValue, size: 12))
|
||||
.background(Color.button)
|
||||
.foregroundColor(Color.white)
|
||||
.multilineTextAlignment(.center)
|
||||
.cornerRadius(20)
|
||||
.padding(.top, 66.7)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.characterId = characterId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tab Bar
|
||||
extension CharacterDetailView {
|
||||
private var tabBar: some View {
|
||||
HStack(spacing: 0) {
|
||||
ChatInnerTab(
|
||||
title: InnerTab.detail.title,
|
||||
isSelected: selectedTab == .detail,
|
||||
onTap: { if selectedTab != .detail { selectedTab = .detail } }
|
||||
)
|
||||
|
||||
ChatInnerTab(
|
||||
title: InnerTab.gallery.title,
|
||||
isSelected: selectedTab == .gallery,
|
||||
onTap: { if selectedTab != .gallery { selectedTab = .gallery } }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Character Image Section
|
||||
extension CharacterDetailView {
|
||||
private var characterImageSection: some View {
|
||||
// 배경 이미지
|
||||
AsyncImage(url: URL(string: viewModel.characterDetail?.imageUrl ?? "https://picsum.photos/400")) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
Rectangle()
|
||||
.fill(Color.gray)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: screenSize().width)
|
||||
.clipped()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Profile Section
|
||||
extension CharacterDetailView {
|
||||
private var profileSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// 이름과 상태
|
||||
HStack(spacing: 8) {
|
||||
Text(viewModel.characterDetail?.name ?? "")
|
||||
.font(.custom(Font.preBold.rawValue, size: 26))
|
||||
.foregroundColor(.white)
|
||||
|
||||
if let characterType = viewModel.characterDetail?.characterType {
|
||||
HStack(spacing: 8) {
|
||||
Text(characterType.rawValue)
|
||||
.font(.custom(Font.preRegular.rawValue, size: 12))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(characterType == .Clone ? Color(hex: "0020C9") : Color(hex: "009D68"))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 설명
|
||||
Text(viewModel.characterDetail?.description ?? "")
|
||||
.font(.custom(Font.preRegular.rawValue, size: 18))
|
||||
.foregroundColor(Color(hex: "B0BEC5"))
|
||||
|
||||
Text(viewModel.characterDetail?.tags ?? "")
|
||||
.font(.custom(Font.preRegular.rawValue, size: 14))
|
||||
.foregroundColor(Color(hex: "3BB9F1"))
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - World View Section
|
||||
extension CharacterDetailView {
|
||||
private func worldViewSection(backgrounds: CharacterBackgroundResponse) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("[세계관 및 작품 소개]")
|
||||
.font(.custom(Font.preBold.rawValue, size: 18))
|
||||
.foregroundColor(.white)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
CharacterExpandableTextView(text: backgrounds.description)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Original Work Section
|
||||
extension CharacterDetailView {
|
||||
private func originalWorkSection(title: String, link: String) -> some View {
|
||||
VStack(spacing: 8) {
|
||||
HStack {
|
||||
Text("원작")
|
||||
.font(.custom(Font.preBold.rawValue, size: 16))
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.white)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.custom(Font.preRegular.rawValue, size: 16))
|
||||
.foregroundColor(Color(hex: "B0BEC5"))
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
if let url = URL(string: link) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}) {
|
||||
Text("원작 보러가기")
|
||||
.font(.custom(Font.preBold.rawValue, size: 16))
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(Color(hex: "3BB9F1"))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color(hex: "3BB9F1"), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 36)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Personality Section
|
||||
extension CharacterDetailView {
|
||||
private func personalitySection(personalities: CharacterPersonalityResponse) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("[성격 및 특징]")
|
||||
.font(.custom(Font.preBold.rawValue, size: 18))
|
||||
.foregroundColor(.white)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
CharacterExpandableTextView(text: personalities.description)
|
||||
|
||||
// 캐릭터톡 대화 가이드
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Text("⚠️ 캐릭터톡 대화 가이드")
|
||||
.font(.custom(Font.preBold.rawValue, size: 16))
|
||||
.foregroundColor(Color(hex: "B0BEC5"))
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Text("""
|
||||
보이스온의 오픈월드 캐릭터톡으로 대화의 자유도가 높아 대화에 참여하는 당신은 누구든 될 수 있습니다. 세계관 속 연관 캐릭터가 되어 대화를 하거나 완전히 새로운 인물이 되어 캐릭터와 당신만의 스토리를 만들어 갈 수 있습니다.
|
||||
""")
|
||||
.font(.custom(Font.preRegular.rawValue, size: 16))
|
||||
.foregroundColor(Color(hex: "AEAEB2"))
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
Text("""
|
||||
오픈월드 캐릭터톡은 캐릭터를 정교하게 설계하였지만, 대화가 어색하거나 불완전할 수도 있습니다.
|
||||
대화 도중 캐릭터의 대화가 이상하거나 새로운 캐릭터로 대화를 나누고 싶다면 대화를 초기화 하고 새롭게 캐릭터와 대화를 나눠보세요.
|
||||
""")
|
||||
.font(.custom(Font.preRegular.rawValue, size: 16))
|
||||
.foregroundColor(Color(hex: "AEAEB2"))
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
.padding(16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(Color(hex: "37474F"), lineWidth: 1)
|
||||
)
|
||||
.cornerRadius(16)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// MARK: - Chat Button
|
||||
extension CharacterDetailView {
|
||||
private var chatButton: some View {
|
||||
Text("대화하기")
|
||||
.font(.custom(Font.preBold.rawValue, size: 18))
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 54)
|
||||
.background(Color.button)
|
||||
.cornerRadius(16)
|
||||
.padding(.horizontal, 24)
|
||||
.onTapGesture {
|
||||
// TODO: 대화하기 액션
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
CharacterDetailView()
|
||||
CharacterDetailView(characterId: 1)
|
||||
}
|
||||
// MARK: - Character Expandable Text View
|
||||
struct CharacterExpandableTextView: View {
|
||||
@State private var isExpanded = false
|
||||
@State private var isTruncated = false
|
||||
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(text)
|
||||
.font(.custom(Font.preRegular.rawValue, size: 16))
|
||||
.foregroundColor(Color(hex: "B0BEC5"))
|
||||
.lineLimit(isExpanded ? nil : 3)
|
||||
.multilineTextAlignment(.leading)
|
||||
.background(
|
||||
GeometryReader { proxy in
|
||||
Color.clear
|
||||
.onAppear {
|
||||
let customFont = UIFont(name: Font.preRegular.rawValue, size: 16) ?? UIFont.systemFont(ofSize: 16)
|
||||
let lineHeight = customFont.lineHeight
|
||||
let maxHeight = lineHeight * 3
|
||||
isTruncated = proxy.size.height > maxHeight && !isExpanded
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if isTruncated || isExpanded {
|
||||
HStack {
|
||||
Spacer()
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(Color(hex: "607D8B"))
|
||||
.rotationEffect(.degrees(isExpanded ? 180 : 0))
|
||||
|
||||
Text(isExpanded ? "간략히" : "더보기")
|
||||
.font(.custom(Font.preRegular.rawValue, size: 16))
|
||||
.foregroundColor(Color(hex: "607D8B"))
|
||||
}
|
||||
.onTapGesture {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
isExpanded.toggle()
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// CharacterDetailViewModel.swift
|
||||
// SodaLive
|
||||
//
|
||||
// Created by klaus on 9/1/25.
|
||||
//
|
||||
import Foundation
|
||||
import Combine
|
||||
import Moya
|
||||
|
||||
final class CharacterDetailViewModel: ObservableObject {
|
||||
// MARK: - Published State
|
||||
@Published private(set) var characterDetail: CharacterDetailResponse?
|
||||
|
||||
@Published var isLoading: Bool = false
|
||||
@Published var errorMessage: String = ""
|
||||
@Published var isShowPopup = false
|
||||
@Published var characterId: Int = 0 {
|
||||
didSet {
|
||||
if characterId > 0 {
|
||||
getCharacterDetail(characterId: characterId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
private let repository = CharacterDetailRepository()
|
||||
private var subscription = Set<AnyCancellable>()
|
||||
|
||||
// MARK: - Public Methods
|
||||
func getCharacterDetail(characterId: Int) {
|
||||
isLoading = true
|
||||
|
||||
repository.getCharacterDetail(characterId: characterId)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { result in
|
||||
switch result {
|
||||
case .finished:
|
||||
DEBUG_LOG("finish")
|
||||
case .failure(let error):
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
}
|
||||
} receiveValue: { [weak self] response in
|
||||
let responseData = response.data
|
||||
self?.isLoading = false
|
||||
|
||||
do {
|
||||
let jsonDecoder = JSONDecoder()
|
||||
let decoded = try jsonDecoder.decode(ApiResponse<CharacterDetailResponse>.self, from: responseData)
|
||||
|
||||
if let data = decoded.data, decoded.success {
|
||||
self?.characterDetail = data
|
||||
} else {
|
||||
if let message = decoded.message {
|
||||
self?.errorMessage = message
|
||||
} else {
|
||||
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
|
||||
}
|
||||
|
||||
self?.isShowPopup = true
|
||||
}
|
||||
} catch {
|
||||
ERROR_LOG(String(describing: error))
|
||||
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
|
||||
self?.isShowPopup = true
|
||||
}
|
||||
}
|
||||
.store(in: &subscription)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// CharacterDetailGalleryView.swift
|
||||
// SodaLive
|
||||
//
|
||||
// Created by klaus on 9/1/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CharacterDetailGalleryView: View {
|
||||
var body: some View {
|
||||
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
CharacterDetailGalleryView()
|
||||
}
|
||||
@@ -248,6 +248,9 @@ struct ContentView: View {
|
||||
case .audition:
|
||||
AuditionView()
|
||||
|
||||
case .characterDetail(let characterId):
|
||||
CharacterDetailView(characterId: characterId)
|
||||
|
||||
default:
|
||||
EmptyView()
|
||||
.frame(width: 0, height: 0, alignment: .topLeading)
|
||||
|
||||
@@ -14,14 +14,14 @@ struct ExpandableTextView: View {
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
let customFont = UIFont(name: Font.medium.rawValue, size: 12.3) ?? UIFont.systemFont(ofSize: 12.3)
|
||||
let customFont = UIFont(name: Font.preRegular.rawValue, size: 12.3) ?? UIFont.systemFont(ofSize: 12.3)
|
||||
|
||||
let lineSpacing = CGFloat(5)
|
||||
let lineHeight = customFont.lineHeight + lineSpacing
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(text)
|
||||
.font(.custom(Font.medium.rawValue, size: 14))
|
||||
.font(.custom(Font.preRegular.rawValue, size: 14))
|
||||
.foregroundColor(Color.gray77)
|
||||
.lineLimit(isExpanded ? nil : 3) // 확장 시 전체 표시, 아니면 3줄로 제한
|
||||
.truncationMode(.tail)
|
||||
|
||||
@@ -26,8 +26,8 @@ struct DetailNavigationBar: View {
|
||||
.frame(width: 20, height: 20)
|
||||
|
||||
Text(title)
|
||||
.font(.custom(Font.bold.rawValue, size: 18.3))
|
||||
.foregroundColor(Color(hex: "eeeeee"))
|
||||
.font(.custom(Font.preBold.rawValue, size: 18.3))
|
||||
.foregroundColor(.grayee)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Reference in New Issue
Block a user