import SwiftUI struct CreatorChannelView: View { let creatorId: Int @StateObject private var viewModel = CreatorChannelViewModel() @StateObject private var channelDonationViewModel = ChannelDonationViewModel() @Environment(\.dismiss) private var dismiss @State private var tabBarMinY: CGFloat = .greatestFiniteMagnitude @State private var isShowChannelDonationDialog = false private let titleBarHeight: CGFloat = 56 private let tabBarHeight: CGFloat = 52 init(creatorId: Int, viewModel: CreatorChannelViewModel = CreatorChannelViewModel()) { self.creatorId = creatorId _viewModel = StateObject(wrappedValue: viewModel) } var body: some View { GeometryReader { proxy in let titleBarBottomY = proxy.safeAreaInsets.top + titleBarHeight let backgroundProgress = clamp(1 - ((tabBarMinY - titleBarBottomY) / titleBarHeight)) let isTabBarSticky = tabBarMinY <= titleBarBottomY ZStack(alignment: .top) { Color.black.ignoresSafeArea() ScrollView(.vertical, showsIndicators: false) { VStack(spacing: 0) { headerSection tabBar .background( GeometryReader { geometry in let minY = geometry.frame(in: .global).minY Color.clear .onAppear { updateTabBarMinY(minY) } .onChange(of: minY) { value in updateTabBarMinY(value) } } ) .opacity(isTabBarSticky ? 0 : 1) selectedTabContent } } .ignoresSafeArea(.container, edges: .vertical) if isTabBarSticky { tabBar .frame(height: tabBarHeight) .offset(y: titleBarHeight) } VStack(spacing: 0) { Color.black .opacity(backgroundProgress) .frame(height: proxy.safeAreaInsets.top) titleBar(backgroundProgress: backgroundProgress) } .ignoresSafeArea(.container, edges: .top) if viewModel.isLoading { LoadingView() } if isShowChannelDonationDialog { LiveRoomDonationDialogView( isShowing: $isShowChannelDonationDialog, isAudioContentDonation: false, messageLimit: 100, secretLabel: I18n.MemberChannel.secretDonationLabel, secretMinimumCanMessage: I18n.MemberChannel.secretDonationMinimumCanMessage, shouldPrefixSecretInMessagePlaceholder: false, onClickDonation: { can, message, isSecret in channelDonationViewModel.postChannelDonation( can: can, message: message, isSecret: isSecret, reloadAfterSuccess: false, onSuccess: { viewModel.fetchHome(creatorId: creatorId) } ) } ) } } } .navigationBarBackButtonHidden(true) .onAppear { if viewModel.hasLoaded == false { viewModel.fetchHome(creatorId: creatorId) } } .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 1) .sodaToast(isPresented: $channelDonationViewModel.isShowPopup, message: channelDonationViewModel.errorMessage, autohideIn: 2) } private func titleBar(backgroundProgress: CGFloat) -> some View { CreatorChannelTitleBar( nickname: viewModel.response?.creator.nickname ?? "", isFollow: viewModel.response?.creator.isFollow ?? false, isNotify: viewModel.response?.creator.isNotify ?? false, backgroundProgress: backgroundProgress, onTapBack: { dismiss() }, onTapFollow: { viewModel.creatorFollow(follow: true, notify: true) }, onTapUnfollow: { viewModel.creatorFollow(follow: false, notify: false) }, onTapNotify: { viewModel.creatorFollow(follow: true, notify: true) }, onTapUnnotify: { viewModel.creatorFollow(follow: true, notify: false) }, onTapMore: {} ) } @ViewBuilder private var headerSection: some View { if let creator = viewModel.response?.creator { CreatorChannelHeaderSection(creator: creator) } } private var tabBar: some View { CreatorChannelTabBar(selectedTab: $viewModel.selectedTab) } private func clamp(_ value: CGFloat) -> CGFloat { max(0, min(value, 1)) } private func updateTabBarMinY(_ value: CGFloat) { guard tabBarMinY != value else { return } DispatchQueue.main.async { tabBarMinY = value } } @ViewBuilder private var selectedTabContent: some View { if viewModel.selectedTab != .home { CreatorChannelPlaceholderTabView(title: viewModel.selectedTab.title) } else if let response = viewModel.response { CreatorChannelHomeView( response: response, onSelectTab: selectTab, onTapContent: showContentDetail, onTapDonate: showDonationDialog ) } else { CreatorChannelPlaceholderTabView(title: viewModel.selectedTab.title) } } private func selectTab(_ tab: CreatorChannelTab) { switch tab { case .audio: viewModel.selectedTab = .audio case .series: viewModel.selectedTab = .series case .community: viewModel.selectedTab = .community case .fanTalk: viewModel.selectedTab = .fanTalk case .donation: viewModel.selectedTab = .donation default: viewModel.selectedTab = tab } } private func showContentDetail(_ contentId: Int) { AppState.shared.setAppStep(step: .contentDetail(contentId: contentId)) } private func showDonationDialog() { guard let creatorId = viewModel.response?.creator.creatorId else { return } channelDonationViewModel.setCreatorId(creatorId, shouldFetch: false) isShowChannelDonationDialog = true } } struct CreatorChannelView_Previews: PreviewProvider { static var previews: some View { CreatorChannelView(creatorId: 1, viewModel: sampleViewModel) } private static var sampleViewModel: CreatorChannelViewModel { let viewModel = CreatorChannelViewModel() viewModel.response = CreatorChannelHomeResponse( creator: CreatorChannelCreatorResponse( creatorId: 1, characterId: 10, nickname: "Soda Creator", profileImageUrl: "https://picsum.photos/600", followerCount: 12345, isAiChatAvailable: true, isDmAvailable: false, isFollow: true, isNotify: true ), currentLive: nil, latestAudioContent: CreatorChannelAudioContentResponse( audioContentId: 1, title: "최신 오디오 콘텐츠 제목", duration: "1:43:25", imageUrl: "https://picsum.photos/300/300", price: 0, isAdult: false, isPointAvailable: true, isFirstContent: true, seriesName: nil, isOriginalSeries: nil, isOwned: false, isRented: false ), channelDonations: [ CreatorChannelDonationResponse( nickname: "팬 이름 50", profileImageUrl: "https://picsum.photos/120/120?1", can: 50, message: "크리에이터가 커뮤니티에 올린 글이 보이는 부분 크리에이터가 커뮤니티에 올린 글이 보이는 부분", createdAtUtc: "2026-07-03T03:44:00Z" ), CreatorChannelDonationResponse( nickname: "팬 이름 100", profileImageUrl: "https://picsum.photos/120/120?2", can: 100, message: "51~100캔 후원 컬러를 확인하는 Preview 샘플입니다.", createdAtUtc: "2026-07-03T03:43:00Z" ), CreatorChannelDonationResponse( nickname: "팬 이름 500", profileImageUrl: "https://picsum.photos/120/120?3", can: 500, message: "101~500캔 후원 컬러를 확인하는 Preview 샘플입니다.", createdAtUtc: "2026-07-03T03:42:00Z" ), CreatorChannelDonationResponse( nickname: "팬 이름 501", profileImageUrl: "https://picsum.photos/120/120?4", can: 501, message: "501캔 이상 후원 컬러를 확인하는 Preview 샘플입니다.", createdAtUtc: "2026-07-03T03:41:00Z" ) ], notices: [], schedules: [], audioContents: [], series: [], communities: [], fanTalk: CreatorChannelFanTalkSummaryResponse(totalCount: 0, latestFanTalk: nil), introduce: "", activity: CreatorChannelActivityResponse( debutDateUtc: nil, dday: "D+1", liveCount: 0, liveDurationHours: 0, liveContributorCount: 0, audioContentCount: 0, seriesCount: 0 ), sns: CreatorChannelSnsResponse( instagramUrl: "", fancimmUrl: "", xurl: "", youtubeUrl: "", kakaoOpenChatUrl: "" ) ) viewModel.hasLoaded = true return viewModel } }