// // LiveRoomView.swift // SodaLive // // Created by klaus on 2023/08/14. // import SwiftUI import Kingfisher import PopupView struct LiveRoomView: View { @State private var isShowingNewChat = false @State private var isShowPhotoPicker = false let columns = [ GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()) ] let chatColumns = [GridItem(.flexible())] @StateObject var keyboardHandler = KeyboardHandler() @StateObject var viewModel = LiveRoomViewModel() var body: some View { ZStack { Color.black.edgesIgnoringSafeArea(.all) VStack(spacing: 0) { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 6.7) { Text( UserDefaults.int(forKey: .userId) == viewModel.liveRoomInfo?.creatorId ? "라이브 종료": "나가기" ) .font(.custom(Font.medium.rawValue, size: 10)) .foregroundColor(Color(hex: "ff5c49")) .padding(.horizontal, 14.3) .padding(.vertical, 8.3) .overlay( RoundedRectangle(cornerRadius: 13.3) .stroke(Color(hex: "ff5c49"), lineWidth: 1) ) .onTapGesture { if let liveRoomInfo = viewModel.liveRoomInfo, liveRoomInfo.creatorId == UserDefaults.int(forKey: .userId) { viewModel.isShowLiveEndPopup = true } else { viewModel.isShowQuitPopup = true } } Spacer() Text(viewModel.isBgOn ? "배경 ON" : "배경 OFF") .font(.custom(Font.medium.rawValue, size: 10)) .foregroundColor(Color(hex: viewModel.isBgOn ? "9970ff" : "eeeeee")) .padding(.horizontal, 14.3) .padding(.vertical, 8.3) .overlay( RoundedRectangle(cornerRadius: 13.3) .stroke(Color(hex: viewModel.isBgOn ? "9970ff" : "bbbbbb"), lineWidth: 1) ) .onTapGesture { viewModel.isBgOn.toggle() } HStack(spacing: 4.7) { Image("ic_share") .resizable() .frame(width: 16, height: 16) } .padding(.horizontal, 14.3) .padding(.vertical, 6) .overlay( RoundedRectangle(cornerRadius: 13.3) .stroke(Color(hex: "bbbbbb"), lineWidth: 1) ) .onTapGesture { viewModel.shareRoom() } if let liveRoomInfo = viewModel.liveRoomInfo, liveRoomInfo.creatorId == UserDefaults.int(forKey: .userId) { HStack(spacing: 4.7) { Image("ic_edit") .resizable() .frame(width: 16, height: 16) } .padding(.horizontal, 14.3) .padding(.vertical, 6) .overlay( RoundedRectangle(cornerRadius: 13.3) .stroke(Color(hex: "bbbbbb"), lineWidth: 1) ) .onTapGesture { viewModel.isShowEditRoomInfoDialog = true } } } .padding(.horizontal, 13.3) if let liveRoomInfo = viewModel.liveRoomInfo { ZStack { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 5.3) { if liveRoomInfo.isAdult { Text("19") .font(.custom(Font.medium.rawValue, size: 12)) .foregroundColor(Color(hex: "e33621")) .padding(.horizontal, 5.3) .padding(.vertical, 3.3) .background(Color(hex: "601d14")) .cornerRadius(2.6) } Text(liveRoomInfo.title) .font(.custom(Font.bold.rawValue, size: 15.3)) .foregroundColor(Color(hex: "eeeeee")) .lineLimit(1) } .padding(.top, 16.7) .padding(.horizontal, 13.3) LiveRoomTopCreatorView( creatorId: liveRoomInfo.creatorId, nickname: liveRoomInfo.creatorNickname, profileImageUrl: liveRoomInfo.creatorProfileUrl, isFollowing: liveRoomInfo.isFollowing, onClickProfile: { if liveRoomInfo.creatorId != UserDefaults.int(forKey: .userId) { viewModel.getUserProfile(userId: liveRoomInfo.creatorId) } }, onClickFollow: { if $0 { viewModel.creatorUnFollow() } else { viewModel.creatorFollow() } } ) .padding(.top, 16.7) .padding(.horizontal, 13.3) Rectangle() .frame(height: 1) .foregroundColor(Color(hex: "909090").opacity(0.3)) .padding(.horizontal, 13.3) .padding(.top, 8) NotificationView(liveRoomInfo: liveRoomInfo) } if viewModel.isMute { Image("img_noti_mute") } } if viewModel.isShowNotice { HStack(alignment: .top, spacing: 8) { Text("[공지]") .font(.custom(Font.bold.rawValue, size: 11.3)) .foregroundColor(.white) AttributedTextView( attributedString: makeAttributedString(liveRoomInfo.notice), lineLimit: viewModel.isExpandNotice ? Int.max : 1 ) { UIApplication.shared.open($0) } .fixedSize(horizontal: false, vertical: true) .lineSpacing(6) } .padding(.horizontal, 26.7) .padding(.vertical, 13.3) .frame(width: screenSize().width, alignment: .leading) .background(Color(hex: "3d2a6c")) .padding(.top, 10) .contentShape(Rectangle()) .onTapGesture { viewModel.isExpandNotice.toggle() } } if !viewModel.isSpeakerFold { HStack(spacing: 0) { Text("스피커") .font(.custom(Font.bold.rawValue, size: 12)) .foregroundColor(Color(hex: "eeeeee")) Spacer() } .padding(.top, 20) .padding(.horizontal, 23.3) LazyVGrid(columns: columns) { ForEach(liveRoomInfo.speakerList, id: \.self) { speaker in VStack(spacing: 6.7) { ZStack { KFImage(URL(string: speaker.profileImage)) .resizable() .scaledToFill() .frame(width: 46.7, height: 46.7, alignment: .top) .clipShape(Circle()) .overlay( Circle() .stroke( Color(hex: "9970ff"), lineWidth: viewModel.activeSpeakers.contains(UInt(speaker.id)) ? 3 : 0 ) ) if viewModel.muteSpeakers.contains(UInt(speaker.id)) { Image("ic_mute") .resizable() .frame(width: 46.7, height: 46.7) } VStack(alignment: .leading, spacing: 0) { HStack(spacing: 0) { Spacer() if liveRoomInfo.creatorId == speaker.id { Image("ic_crown") .resizable() .frame(width: 16.7, height: 16.7) } } Spacer() } } .frame(width: 46.7, height: 46.7) Text(speaker.nickname) .font(.custom(Font.light.rawValue, size: 12)) .foregroundColor(Color(hex: "bbbbbb")) .lineLimit(1) } .onTapGesture { viewModel.selectedProfile = speaker viewModel.isShowProfilePopup = true } } } .padding(.top, 16.7) .padding(.horizontal, 23.3) } } } .padding(.vertical, 16.7) .frame(width: screenSize().width) .background(Color(hex: "222222")) .cornerRadius(16.7, corners: [.topLeft, .topRight]) ZStack(alignment: .top) { ScrollViewReader { proxy in ZStack(alignment: .bottomTrailing) { if let liveRoomInfo = viewModel.liveRoomInfo, viewModel.isBgOn { GeometryReader { proxy in KFImage(URL(string: liveRoomInfo.coverImageUrl)) .resizable() .scaledToFill() .frame(width: proxy.size.width, height: proxy.size.height, alignment: .center) .clipped() Color.black.opacity(0.4) } } VStack(alignment: .leading, spacing: 0) { ScrollView(.vertical, showsIndicators: false) { scrollObservableView ChatView() .frame(width: screenSize().width) } .rotationEffect(Angle(degrees: 180)) .onTapGesture { hideKeyboard() } .onPreferenceChange(ScrollOffsetKey.self) { viewModel.setOffset($0) } InputChatView { isShowingNewChat = false proxy.scrollTo(viewModel.messages.count - 1, anchor: .center) }.padding(.bottom, 10) } VStack(spacing: 13.3) { if viewModel.role == .SPEAKER { Image(viewModel.isMute ? "ic_mic_off" : "ic_mic_on") .resizable() .frame(width: 26.7, height: 26.7) .padding(11) .background(Color(hex: "525252").opacity(0.6)) .cornerRadius(10) .padding(.bottom, 13.3) .onTapGesture { viewModel.toggleMute() } } Image(viewModel.isSpeakerMute ? "ic_speaker_off" : "ic_speaker_on") .resizable() .frame(width: 26.7, height: 26.7) .padding(11) .background(Color(hex: "525252").opacity(0.6)) .cornerRadius(10) .padding(.bottom, 13.3) .onTapGesture { viewModel.toggleSpeakerMute() } if let liveRoomInfo = viewModel.liveRoomInfo, liveRoomInfo.creatorId == UserDefaults.int(forKey: .userId) && UserDefaults.string(forKey: .role) == MemberRole.CREATOR.rawValue { Image("ic_donation_message_list") .resizable() .frame(width: 26.7, height: 26.7) .padding(11) .background(Color(hex: "525252").opacity(0.6)) .cornerRadius(10) .onTapGesture { viewModel.isShowDonationMessagePopup = true } } else { Image("ic_donation") .resizable() .frame(width: 26.7, height: 26.7) .padding(11) .background(Color(hex: "525252").opacity(0.6)) .cornerRadius(10) .onTapGesture { viewModel.isShowDonationPopup = true } } } .padding(.trailing, 16.7) .padding(.bottom, 85) if isShowingNewChat { NewChatView{ isShowingNewChat = false proxy.scrollTo(viewModel.messages.count - 1, anchor: .center) }.padding(.bottom, 70) } } .frame(width: screenSize().width) .animation(nil) } HStack(spacing: 0) { Spacer() HStack(spacing: 6.7) { Image(viewModel.isSpeakerFold ? "ic_live_detail_bottom" : "ic_live_detail_top") .resizable() .frame(width: 20, height: 20) Text(viewModel.isSpeakerFold ? "펼치기" : "접기") .font(.custom(Font.medium.rawValue, size: 13.3)) .foregroundColor(Color(hex: "bbbbbb")) } .padding(.vertical, 6.7) .padding(.horizontal, 13.3) .background(Color(hex: "222222")) .cornerRadius(10, corners: [.bottomLeft, .bottomRight]) .onTapGesture { viewModel.isSpeakerFold.toggle() } } .background( LinearGradient( gradient: Gradient(colors: [Color(hex: "222222").opacity(0.95), Color.black.opacity(0.005)]), startPoint: .top, endPoint: .bottom ).ignoresSafeArea() ) } } .popup(isPresented: $viewModel.isShowErrorPopup, type: .toast, position: .top, autohideIn: 1.3) { GeometryReader { geo in HStack { Spacer() Text(viewModel.errorMessage) .padding(.vertical, 13.3) .frame(width: geo.size.width - 66.7, alignment: .center) .font(.custom(Font.medium.rawValue, size: 12)) .background(Color(hex: "9970ff")) .foregroundColor(Color.white) .multilineTextAlignment(.center) .cornerRadius(20) .padding(.top, 66.7) Spacer() } .onDisappear { viewModel.quitRoom() } } } .cornerRadius(16.7, corners: [.topLeft, .topRight]) .offset(y: -(keyboardHandler.keyboardHeight > 0 ? keyboardHandler.keyboardHeight : 0)) .onAppear { UIApplication.shared.isIdleTimerDisabled = true UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) viewModel.getMemberCan() viewModel.initAgoraEngine() viewModel.getRoomInfo() NotificationCenter.default.addObserver( forName: UIApplication.willTerminateNotification, object: nil, queue: .main) { _ in viewModel.quitRoom() sleep(3) } } .onDisappear { UIApplication.shared.isIdleTimerDisabled = false NotificationCenter.default.removeObserver(self) } ZStack { if viewModel.isShowProfilePopup, let liveRoomInfo = viewModel.liveRoomInfo, let selectedProfile = viewModel.selectedProfile { LiveRoomProfileDialog( isShowing: $viewModel.isShowProfilePopup, profileInfo: selectedProfile, creatorId: liveRoomInfo.creatorId, isSpeaker: viewModel.role == .SPEAKER, onClickInviteSpeaker: { inviteSpeaker(peerId: $0) }, onClickChangeListener: { if $0 == UserDefaults.int(forKey: .userId) { viewModel.setListener() return } viewModel.changeListener(peerId: $0) } ) } if viewModel.isShowDonationPopup { LiveRoomDonationDialogView(isShowing: $viewModel.isShowDonationPopup, isAudioContentDonation: false) { can, message in viewModel.donation(can: can, message: message) } } if viewModel.isShowQuitPopup { SodaDialog( title: "라이브 나가기", desc: "라이브에서 나가시겠습니까?", confirmButtonTitle: "예", confirmButtonAction: { viewModel.isShowQuitPopup = false viewModel.quitRoom() }, cancelButtonTitle: "아니오", cancelButtonAction: { viewModel.isShowQuitPopup = false } ) } if viewModel.isShowLiveEndPopup { SodaDialog( title: "라이브 종료", desc: "라이브를 종료하시겠습니까?\n" + "라이브를 종료하면 대화내용은\n" + "저장되지 않고 사라집니다.\n" + "참여자들 또한 라이브가 종료되어\n" + "강제퇴장 됩니다.", confirmButtonTitle: "예", confirmButtonAction: { viewModel.isShowLiveEndPopup = false viewModel.quitRoom() }, cancelButtonTitle: "아니오", cancelButtonAction: { viewModel.isShowLiveEndPopup = false } ) } } ZStack { if viewModel.isShowProfileList, let liveRoomInfo = viewModel.liveRoomInfo { LiveRoomProfilesDialogView( isShowing: $viewModel.isShowProfileList, viewModel: viewModel, roomInfo: liveRoomInfo, registerNotification: { viewModel.creatorFollow() }, unRegisterNotification: { viewModel.creatorUnFollow() }, onClickProfile: { if $0 != UserDefaults.int(forKey: .userId) { viewModel.getUserProfile(userId: $0) } }, onClickNoChatting: { userId, nickname, profileUrl in viewModel.noChattingUserId = userId viewModel.noChattingUserNickname = nickname viewModel.noChattingUserProfileUrl = profileUrl viewModel.isShowNoChattingConfirm = true } ) } if viewModel.isShowUserProfilePopup, let userProfile = viewModel.userProfile { Color.black.opacity(0.7) .edgesIgnoringSafeArea(.all) LiveRoomUserProfileDialogView( isShowing: $viewModel.isShowUserProfilePopup, viewModel: viewModel, userProfile: userProfile, onClickSetManager: { viewModel.setManagerMessageToPeer(userId: $0) viewModel.setManager(userId: $0) }, onClickReleaseManager: { viewModel.changeListener(peerId: $0, isFromManager: true) }, onClickFollow: { viewModel.creatorFollow(creatorId: $0, isGetUserProfile: true) }, onClickUnFollow: { viewModel.creatorUnFollow(creatorId: $0, isGetUserProfile: true) }, onClickInviteSpeaker: { inviteSpeaker(peerId: $0) }, onClickChangeListener: { viewModel.changeListener(peerId: $0) }, onClickMenu: { userId, userNickname, isBlocked in viewModel.reportUserId = userId viewModel.reportUserNickname = userNickname viewModel.reportUserIsBlocked = isBlocked viewModel.isShowReportMenu = true }, onClickNoChatting: { userId, nickname, profileUrl in viewModel.noChattingUserId = userId viewModel.noChattingUserNickname = nickname viewModel.noChattingUserProfileUrl = profileUrl viewModel.isShowNoChattingConfirm = true } ) .padding(20) .popup(isPresented: $viewModel.isShowReportPopup, type: .toast, position: .top, autohideIn: 1.3) { GeometryReader { geo in HStack { Spacer() Text(viewModel.reportMessage) .padding(.vertical, 13.3) .frame(width: geo.size.width - 66.7, alignment: .center) .font(.custom(Font.medium.rawValue, size: 12)) .background(Color(hex: "9970ff")) .foregroundColor(Color.white) .multilineTextAlignment(.center) .cornerRadius(20) .padding(.top, 66.7) Spacer() } } } } if viewModel.isShowReportMenu { VStack(spacing: 0) { ProfileReportMenuView( isShowing: $viewModel.isShowReportMenu, isBlockedUser: viewModel.reportUserIsBlocked, userBlockAction: { viewModel.isShowUesrBlockConfirm = true }, userUnBlockAction: { viewModel.userUnBlock() }, userReportAction: { viewModel.isShowUesrReportView = true }, profileReportAction: { viewModel.isShowProfileReportConfirm = true } ) Rectangle() .foregroundColor(Color(hex: "222222")) .frame(width: screenSize().width, height: 15.3) } .ignoresSafeArea() } if viewModel.isShowUesrBlockConfirm { UserBlockConfirmDialogView( isShowing: $viewModel.isShowUesrBlockConfirm, nickname: viewModel.reportUserNickname, confirmAction: { viewModel.userBlock() } ) } if viewModel.isShowUesrReportView { UserReportDialogView( isShowing: $viewModel.isShowUesrReportView, confirmAction: { reason in viewModel.report(type: .USER, reason: reason) } ) } if viewModel.isShowProfileReportConfirm { ProfileReportDialogView( isShowing: $viewModel.isShowProfileReportConfirm, confirmAction: { viewModel.report(type: .PROFILE) } ) } if viewModel.isShowNoChattingConfirm && viewModel.noChattingUserId > 0 { LiveRoomNoChattingDialogView( nickname: viewModel.noChattingUserNickname, profileUrl: viewModel.noChattingUserProfileUrl, confirmAction: { viewModel.isShowNoChattingConfirm = false viewModel.setNoChatting() }, cancelAction: { viewModel.noChattingUserId = 0 viewModel.noChattingUserNickname = "" viewModel.noChattingUserProfileUrl = "" viewModel.isShowNoChattingConfirm = false } ) } if viewModel.isShowPopup { LiveRoomDialogView( content: viewModel.popupContent, cancelTitle: viewModel.popupCancelTitle, cancelAction: viewModel.popupCancelAction, confirmTitle: viewModel.popupConfirmTitle, confirmAction: viewModel.popupConfirmAction ).onAppear { if viewModel.popupConfirmTitle == nil && viewModel.popupConfirmAction == nil { DispatchQueue.main.asyncAfter(deadline: .now() + 2) { viewModel.isShowPopup = false viewModel.popupCancelTitle = nil viewModel.popupCancelAction = nil viewModel.popupConfirmTitle = nil viewModel.popupConfirmAction = nil } } } } } if viewModel.isLoading && viewModel.liveRoomInfo == nil { LoadingView() } } .ignoresSafeArea(.keyboard) .edgesIgnoringSafeArea(keyboardHandler.keyboardHeight > 0 ? .bottom : .init()) .transaction { transaction in transaction.animation = nil } .sheet( isPresented: $viewModel.isShowShareView, onDismiss: { viewModel.shareMessage = "" }, content: { ActivityViewController(activityItems: [viewModel.shareMessage]) } ) .sheet(isPresented: $isShowPhotoPicker) { ImagePicker( isShowing: $isShowPhotoPicker, selectedImage: $viewModel.coverImage, sourceType: .photoLibrary ) } .sheet(isPresented: $viewModel.isShowEditRoomInfoDialog) { if let liveRoomInfo = viewModel.liveRoomInfo { LiveRoomInfoEditDialog( isShowing: $viewModel.isShowEditRoomInfoDialog, isShowPhotoPicker: $isShowPhotoPicker, viewModel: viewModel, isLoading: viewModel.isLoading, currentTitle: liveRoomInfo.title, currentNotice: liveRoomInfo.notice, coverImageUrl: liveRoomInfo.coverImageUrl, coverImage: viewModel.coverImage ) { newTitle, newNotice in self.viewModel.editLiveRoomInfo( title: newTitle, notice: newNotice ) } } else { EmptyView() .onAppear { viewModel.isShowEditRoomInfoDialog = false } } } .sheet(isPresented: $viewModel.isShowDonationRankingPopup) { LiveRoomDonationRankingDialog(isShowing: $viewModel.isShowDonationRankingPopup) } .sheet(isPresented: $viewModel.isShowDonationMessagePopup) { LiveRoomDonationMessageDialog(isShowing: $viewModel.isShowDonationMessagePopup) } } func makeAttributedString(_ text: String) -> NSAttributedString { let attributedString = NSMutableAttributedString(string: text) let urlRegex = try! NSRegularExpression(pattern: "\\b(https?://\\S+\\b|www\\.\\S+\\b)") let matches = urlRegex.matches(in: text, options: [], range: NSRange(text.startIndex..., in: text)) for match in matches { let url = (text as NSString).substring(with: match.range) if let detectedURL = URL(string: url) { attributedString.addAttribute(.link, value: detectedURL, range: match.range) } } return attributedString } private func inviteSpeaker(peerId: Int) { if viewModel.liveRoomInfo!.speakerList.count <= 4 { viewModel.inviteSpeaker(peerId: peerId) self.viewModel.popupContent = "스피커 요청을 보냈습니다.\n잠시만 기다려 주세요." self.viewModel.isShowPopup = true } else { viewModel.popupContent = "스피커 정원을 초과했습니다." viewModel.isShowPopup = true } } private var scrollObservableView: some View { GeometryReader { proxy in let offsetY = proxy.frame(in: .global).origin.y Color.clear .preference( key: ScrollOffsetKey.self, value: offsetY ) .onAppear { viewModel.setOriginOffset(offsetY) } } .frame(height: 0) } struct ScrollOffsetKey: PreferenceKey { static var defaultValue: CGFloat = .zero static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value += nextValue() } } @ViewBuilder func NotificationView(liveRoomInfo: GetRoomInfoResponse) -> some View { HStack(spacing: 8) { Image( viewModel.isShowNotice ? "ic_notice_selected" : "ic_notice_normal" ) .contentShape(Rectangle()) .onTapGesture { viewModel.isShowNotice.toggle() } .padding(.trailing, 10) Spacer() HStack(spacing: 4.7) { Image("ic_can") .resizable() .frame(width: 16, height: 16) Text("\(viewModel.totalDonationCan)") .font(.custom(Font.medium.rawValue, size: 12)) .foregroundColor(Color(hex: "bbbbbb")) } .padding(.horizontal, 11.5) .padding(.vertical, 5.3) .overlay( RoundedRectangle(cornerRadius: 12.8) .strokeBorder(lineWidth: 1) .foregroundColor(Color(hex: "eeeeee")) ) .contentShape(Rectangle()) .onTapGesture { viewModel.isShowDonationRankingPopup = true } if liveRoomInfo.creatorId == UserDefaults.int(forKey: .userId) { HStack(spacing: 0) { Text("참여자") .font(.custom(Font.medium.rawValue, size: 12)) .foregroundColor(Color(hex: "bbbbbb")) Text("\(liveRoomInfo.participantsCount)") .font(.custom(Font.medium.rawValue, size: 12)) .foregroundColor(Color(hex: "9970ff")) .padding(.leading, 6.7) } .padding(.horizontal, 11.5) .padding(.vertical, 7.3) .overlay( RoundedRectangle(cornerRadius: 12.8) .strokeBorder(lineWidth: 1) .foregroundColor(Color(hex: "eeeeee")) ) .contentShape(Rectangle()) .onTapGesture { viewModel.isShowProfileList = true } } } .padding(.top, 13.3) .padding(.horizontal, 13.3) } @ViewBuilder func NewChatView(scrollToBottom: @escaping () -> Void) -> some View { HStack(spacing: 0) { Spacer() HStack(spacing: 6.7) { Image("ic_bottom_white") Text("새로운 채팅") .font(.custom(Font.medium.rawValue, size: 13.3)) .foregroundColor(Color(hex: "eeeeee")) } .padding(.vertical, 8) .padding(.horizontal, 13.3) .background(Color(hex: "555555").opacity(0.8)) .cornerRadius(16.7) .padding(.bottom, 13.3) .onTapGesture { scrollToBottom() } Spacer() } } @ViewBuilder func InputChatView(scrollToBottom: @escaping () -> Void) -> some View { HStack(spacing: 0) { TextField("채팅을 입력하세요", text: $viewModel.chatMessage) .autocapitalization(.none) .disableAutocorrection(true) .font(.custom(Font.medium.rawValue, size: 13.3)) .foregroundColor(Color(hex: "eeeeee")) .accentColor(Color(hex: "3bb9f1")) .keyboardType(.default) .padding(.horizontal, 13.3) .onTapGesture { if viewModel.isNoChatting { viewModel.popupContent = "\(viewModel.remainingNoChattingTime)초 동안 채팅하실 수 없습니다" viewModel.isShowPopup = true } } Spacer() Image("btn_message_send") .resizable() .frame(width: 35, height: 35) .padding(6.7) .onTapGesture { viewModel.sendMessage() scrollToBottom() } } .background(Color(hex: "232323")) .cornerRadius(10) .overlay( RoundedRectangle(cornerRadius: 10) .strokeBorder(lineWidth: 1) .foregroundColor(Color(hex: "eeeeee")) ) .padding(13.3) } @ViewBuilder func ChatView() -> some View { LazyVGrid(columns: chatColumns, alignment: .leading, spacing: 20) { ForEach(0.. (56.7 * 2) { isShowingNewChat = true } } } } struct LiveRoomView_Previews: PreviewProvider { static var previews: some View { LiveRoomView() } } struct AttributedTextView: UIViewRepresentable { let attributedString: NSAttributedString let lineLimit: Int let onURLTapped: (URL) -> Void func makeUIView(context: Context) -> UITextView { let textView = UITextView() textView.isEditable = false textView.isSelectable = false textView.isScrollEnabled = false textView.backgroundColor = .clear textView.font = UIFont(name: Font.light.rawValue, size: 11.3) textView.textColor = .white textView.textContainer.lineFragmentPadding = 0 textView.textContainerInset = .zero textView.textContainer.maximumNumberOfLines = lineLimit textView.textContainer.lineBreakMode = lineLimit == 1 ? .byTruncatingTail : .byWordWrapping textView.delegate = context.coordinator textView.isUserInteractionEnabled = true // Add tap gesture recognizer to handle URL tap events let tapGestureRecognizer = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTapGesture(_:))) textView.addGestureRecognizer(tapGestureRecognizer) return textView } func updateUIView(_ uiView: UITextView, context: Context) { uiView.attributedText = attributedString uiView.textColor = UIColor.white uiView.textContainer.maximumNumberOfLines = lineLimit uiView.textContainer.lineBreakMode = lineLimit == 1 ? .byTruncatingTail : .byWordWrapping } func makeCoordinator() -> Coordinator { Coordinator(onURLTapped: onURLTapped) } class Coordinator: NSObject, UITextViewDelegate { let onURLTapped: (URL) -> Void let linkAttributeName = NSAttributedString.Key.link.rawValue init(onURLTapped: @escaping (URL) -> Void) { self.onURLTapped = onURLTapped } @objc func handleTapGesture(_ gesture: UITapGestureRecognizer) { let textView = gesture.view as? UITextView let location = gesture.location(in: textView) let layoutManager = textView?.layoutManager let characterIndex = layoutManager?.characterIndex(for: location, in: textView!.textContainer, fractionOfDistanceBetweenInsertionPoints: nil) if characterIndex != NSNotFound { let attributedString = textView?.attributedText attributedString?.enumerateAttribute(NSAttributedString.Key(rawValue: linkAttributeName), in: NSRange(location: 0, length: attributedString!.length), options: []) { value, range, _ in if let url = value as? URL, NSLocationInRange(characterIndex!, range) { onURLTapped(url) } } } } } }