import SwiftUI struct CreatorChannelReplyDetailFeedView: View { let parentItem: CreatorChannelReplyDetailDisplayItem let replies: [CreatorChannelReplyDetailDisplayItem] let selectedActionItem: CreatorChannelReplyDetailDisplayItem? let canEdit: (CreatorChannelReplyDetailDisplayItem) -> Bool let canDelete: (CreatorChannelReplyDetailDisplayItem) -> Bool let onTapAction: (CreatorChannelReplyDetailDisplayItem) -> Void let onTapEdit: (CreatorChannelReplyDetailDisplayItem) -> Void let onTapDelete: (CreatorChannelReplyDetailDisplayItem) -> Void let onReplyAppear: (CreatorChannelReplyDetailDisplayItem) -> Void @State private var itemFrames = [CreatorChannelReplyDetailItemKey: CGRect]() var body: some View { ZStack(alignment: .topTrailing) { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { parentView if replies.isEmpty == false { repliesView .padding(.top, SodaSpacing.s12) .padding(.leading, 50) } Spacer(minLength: SodaSpacing.s20) } .padding(.horizontal, SodaSpacing.s14) .padding(.top, SodaSpacing.s16) .padding(.bottom, SodaSpacing.s20) } actionPopupLayer .zIndex(1) } .coordinateSpace(name: Self.coordinateSpaceName) .onPreferenceChange(CreatorChannelReplyDetailItemFramePreferenceKey.self) { frames in itemFrames.merge(frames) { _, new in new } } } @ViewBuilder private var actionPopupLayer: some View { if let selectedActionItem, let itemFrame = itemFrames[CreatorChannelReplyDetailItemKey(item: selectedActionItem)] { CreatorChannelReplyDetailActionPopup( canEdit: canEdit(selectedActionItem), canDelete: canDelete(selectedActionItem), onTapEdit: { onTapEdit(selectedActionItem) }, onTapDelete: { onTapDelete(selectedActionItem) } ) .padding(.top, itemFrame.minY + 32) .padding(.trailing, SodaSpacing.s14) } } private var parentView: some View { VStack(alignment: .leading, spacing: SodaSpacing.s12) { itemHeader(item: parentItem, profileSize: 42, showsSecret: true) Text(parentItem.content) .appFont(size: 16, weight: .regular) .foregroundColor(.white) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) .padding(.leading, 50) .background(alignment: .topLeading) { if replies.isEmpty == false { GeometryReader { proxy in CreatorChannelReplyDetailConnector() .stroke(Color.gray800, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) .frame(height: proxy.size.height + SodaSpacing.s12 + SodaSpacing.s20) } } } } } private var repliesView: some View { VStack(alignment: .leading, spacing: SodaSpacing.s8) { ForEach(replies) { reply in replyCard(reply) .onAppear { onReplyAppear(reply) } } } } private func replyCard(_ item: CreatorChannelReplyDetailDisplayItem) -> some View { VStack(alignment: .leading, spacing: SodaSpacing.s8) { itemHeader(item: item, profileSize: 20, showsSecret: false) Text(item.content) .appFont(size: 16, weight: .regular) .foregroundColor(.white) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) } .padding(SodaSpacing.s12) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.gray900) .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) } private func itemHeader( item: CreatorChannelReplyDetailDisplayItem, profileSize: CGFloat, showsSecret: Bool ) -> some View { HStack(alignment: .center, spacing: SodaSpacing.s8) { DownsampledKFImage( url: URL(string: item.profileImageUrl), size: CGSize(width: profileSize, height: profileSize) ) .background(Color.white.opacity(0.6)) .clipShape(Circle()) Text(item.nickname) .appFont(size: profileSize > 20 ? 14 : 13, weight: .medium) .foregroundColor(.white) .lineLimit(1) Text(item.relativeTimeText) .appFont(size: 14, weight: .regular) .foregroundColor(Color.gray500) .lineLimit(1) if showsSecret && item.isSecret { Text(I18n.Explorer.secretComment) .appFont(size: 11, weight: .medium) .foregroundColor(Color.grayee) .padding(.horizontal, 4) .padding(.vertical, 2) .background(Color.soda400.opacity(0.2)) .cornerRadius(3) } Spacer(minLength: 0) if canEdit(item) || canDelete(item) { Button(action: { onTapAction(item) }) { Image("ic_new_more") .resizable() .frame(width: 24, height: 24) } .buttonStyle(.plain) } } .trackReplyDetailItemFrame(item) } fileprivate static let coordinateSpaceName = "CreatorChannelReplyDetailFeedView" } private struct CreatorChannelReplyDetailItemKey: Hashable { let id: Int let source: Int init(item: CreatorChannelReplyDetailDisplayItem) { id = item.id switch item.source { case .community: source = 0 case .fanTalk: source = 1 } } } private struct CreatorChannelReplyDetailItemFramePreferenceKey: PreferenceKey { static var defaultValue: [CreatorChannelReplyDetailItemKey: CGRect] = [:] static func reduce( value: inout [CreatorChannelReplyDetailItemKey: CGRect], nextValue: () -> [CreatorChannelReplyDetailItemKey: CGRect] ) { value.merge(nextValue()) { _, new in new } } } private extension View { func trackReplyDetailItemFrame(_ item: CreatorChannelReplyDetailDisplayItem) -> some View { background { GeometryReader { proxy in Color.clear.preference( key: CreatorChannelReplyDetailItemFramePreferenceKey.self, value: [CreatorChannelReplyDetailItemKey(item: item): proxy.frame(in: .named(CreatorChannelReplyDetailFeedView.coordinateSpaceName))] ) } } } } private struct CreatorChannelReplyDetailConnector: Shape { func path(in rect: CGRect) -> Path { var path = Path() let avatarCenterX: CGFloat = 21 let replyCardStartX: CGFloat = 50 let connectorGap: CGFloat = 14 let connectorEndX = replyCardStartX - connectorGap let startY: CGFloat = 0 let endY = rect.height - 8 let bendY = max(startY, endY - 14) path.move(to: CGPoint(x: avatarCenterX, y: startY)) path.addLine(to: CGPoint(x: avatarCenterX, y: bendY)) path.addQuadCurve( to: CGPoint(x: connectorEndX, y: endY), control: CGPoint(x: avatarCenterX, y: endY) ) return path } }