iOS

[iOS/SwiftUI] SwiftUI 에서 SocketiO 사용하기 (2)

GODOLs 2023. 10. 11. 13:48

지난번 ViewModel + SocketiO 설정에 이어 SwiftUI에서 어떻게 구현되는지 설명 드리겠습니다.

import SwiftUI
import SDWebImageSwiftUI

struct ChatView: View {
    @State private var messageText = ""
    @State private var messageTextHolder = "내용을 입력하세요"
    @State private var showPaymentAlert = false
    @State private var paymentSucceed = false
    @ObservedObject private var keyboardResponder = KeyboardResponder()
    @EnvironmentObject var chatViewModel:ChatViewModel
    @EnvironmentObject var appState:NavigationPathViewModel
    @Binding var roomId:String
    @Binding var partnerProfileImage:String
    @Binding var partnerNickname:String
    @Binding var partnerId:String
    @Binding var onAppearFromLobbyChat:Bool
    var firstChat:Bool
    @Environment(\.scenePhase) var scenePhase
    @State private var tabBarVisible: Bool = false
    @Environment(\.presentationMode) var presentationMode
    var buttonBack : some View {
        Button(action: {
            self.chatViewModel.leaveRoom(roomId: roomId)
            self.tabBarVisible.toggle()
            if self.firstChat {
                self.appState.rootViewId = UUID()
                self.appState.selectedTab = .tab3

            }else{
                self.onAppearFromLobbyChat = true
                self.presentationMode.wrappedValue.dismiss()
            }

        }) {
            HStack {
                Image("back_button") // set image here
                    .aspectRatio(contentMode: .fit)
                    .foregroundColor(.white)

            }
        }
    }

    var body: some View {
        ZStack{
            VStack{
                ScrollView{
                    ScrollViewReader { scrollView in
                        ChatIntroduceView()
                        ForEach(chatViewModel.messages.indices, id: \.self) { index in
                            ChatMessageRow(message: chatViewModel.messages[index], profileImage: partnerProfileImage, lobbyistId: partnerId)
                                .id(index)
                                .padding(.bottom,5)
                        }
                        .onChange(of: chatViewModel.messages.count) { _ in
                            // 새로운 메시지가 추가될 때마다 스크롤뷰를 최하단으로 이동시킵니다.
                            withAnimation {
                                scrollView.scrollTo(chatViewModel.messages.count - 1, anchor: .bottom)
                            }
                        }


                    }

                }
                .hideKeyboardOnTap()
                Spacer()
                HStack{
                    RoundedRectangle(cornerRadius: 5)
                        .stroke(Color("brownish_grey"), lineWidth: 1)
                        .overlay(

                                ZStack{


                                    TextEditor(text: $messageText)
                                        .modifier(Medium(size: 12))

                                        .frame(height:30)
                                        .foregroundColor(.black)
                                        .padding(15)

                                    if messageText.isEmpty{
                                        TextEditor(text: $messageTextHolder)
                                            .modifier(Medium(size: 12))
                                            .frame(height:30)
                                            .foregroundColor(Color("vivid_grey"))
                                            .padding(15)
                                            .opacity(messageText.isEmpty ? 1 : 0.5)
                                            .disabled(true)

                                    }
                                }



                        )
                    Button(action: {
                        chatViewModel.sendMessage(messageText: messageText.trimmingCharacters(in: .whitespaces), roomId: roomId)
                        messageText = ""
                    }, label: {
                        Image(messageText.isEmpty ? "send" : "send_black")
                            .padding(.trailing,8)
                            .disabled(messageText.isEmpty)
                            .foregroundColor(Color("brownish_grey"))
                    })
                }
                .frame(height: 35)
                .padding(.vertical,10)
            }
            .hideKeyboardOnTap()
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .navigationBarLeading) {
                    HStack {
                        buttonBack

                        WebImage(url: URL(string: "\(SHAREVAR.IMAGE_URL)/\(partnerProfileImage)")) // SDWebImageSwiftUI의 WebImage를 사용하여 프로필 이미지를 로드합니다.
                            .resizable()
                            .aspectRatio(contentMode: .fill)
                            .frame(width: 24, height: 24)

                            .clipShape(Circle())
                        Text(partnerNickname) // 상대방의 닉네임을 표시합니다.
                            .modifier(SemiBold(size: 15))
                    }

                }
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button(action: {
                        withAnimation{
                            showPaymentAlert = true
                        }

                    }) {
                        RoundedRectangle(cornerRadius: 7)
                            .foregroundColor(.black)
                            .overlay(
                                HStack{
                                    Image("payment_white")
                                        .resizable()
                                        .frame(width:17, height: 17)
                                    Text("*** **")
                                        .modifier(SemiBold(size: 11))
                                }
                                .foregroundColor(.white)

                            )

                            .frame(width:92, height: 28)
                    }
                }


            }
            .onAppear{
                chatViewModel.joinRoom(roomId: roomId)
            }
            .onChange(of: scenePhase, perform: { newScenePhase in
                switch newScenePhase {
                case .active:
                    print("App is active = chatview \(roomId)")
                    chatViewModel.setupSocket(){
                        chatViewModel.joinRoom(roomId: roomId)
                    }
                case .inactive, .background:
                    print("App is in inactive or background = chatview")
                    chatViewModel.disconnectSocket()
                @unknown default:
                    print("Unknown")
                }
            })

            .navigationBarBackButtonHidden(true)
            .toolbar(tabBarVisible ? .visible : .hidden, for: .tabBar)
            .toolbar(showPaymentAlert ? .hidden : .visible, for: .navigationBar)
            .padding(.horizontal,22)  // VSTACK
            if showPaymentAlert{
                ChatPaymentConfirmView(showPaymentAlert: $showPaymentAlert, roomId: $roomId, paymentSucceed: $paymentSucceed)
            }

        }


    }

}

뷰의 구조

SwiftUI 내부 채팅 구조는
VStack이 아닌 LazyVStack으로 채팅뷰를 구현했습니다.
이유는 이러합니다

  • 성능 최적화: LazyVStack은 그 내부의 뷰들을 "게으르게" 로딩합니다. 즉, 해당 뷰가 화면에 실제로 표시될 때까지 뷰의 생성과 렌더링을 지연시킵니다. 이러한 방식은 메시지 목록과 같이 크고 동적인 목록을 표시할 때 메모리와 CPU를 절약하게 됩니다.
  • 동적 컨텐츠 처리: 채팅 환경에서는 메시지가 계속해서 추가됩니다. LazyVStack을 사용하면 새로운 메시지가 추가될 때마다 전체 메시지 목록을 다시 로드할 필요가 없습니다. 오직 새로 추가된 메시지만 로드되므로, 앱의 반응성이 향상됩니다.
  • 스크롤 관리: LazyVStack은 ScrollView와 함께 사용될 때 최적의 성능을 발휘합니다. 채팅 애플리케이션에서는 대개 많은 수의 메시지가 스크롤 가능한 목록으로 표시되므로, LazyVStack은 이러한 환경에서 자연스럽게 잘 작동합니다.
  • 데이터 로딩의 유연성: LazyVStack을 사용하면 데이터를 비동기적으로 로드하면서도 사용자에게 빠르게 UI를 표시할 수 있습니다. 이를 통해 앱이 더욱 반응적으로 느껴집니다.

1. 함수별 기능 설명

  • buttonBack: 뒤로 가기 버튼의 기능을 정의합니다. 채팅방을 나갈 때와 탭바의 표시를 변경할 때 사용됩니다.
  • body: 이 부분에서 주요 뷰 구성 요소와 뷰의 행동이 정의됩니다.
  • ChatMessageRow: 각 메시지를 표시하는 뷰입니다.
struct ChatMessageRow: View {
    @EnvironmentObject var appState:NavigationPathViewModel
    @EnvironmentObject var userInformationViewModel: UserInformationViewModel
    let message: ChatMessage
    let profileImage: String
    let lobbyistId: String
    var body: some View {
        HStack (alignment: .bottom) {
            if message.isCurrentUser {
                Spacer()
                if message.createdTime != "sending"{
                    Text("\(convertToHourAndMinute(dateString:message.createdTime))")
                        .modifier(Medium(size: 10))
                }else{
                    Image("chat_dark")
                        .resizable()
                        .frame(width: 10,height: 10)
                }

                // 메시지의 타입에 따라 분기 처리를 합니다.
                switch message.messageType {
                case .text:
                    Text(message.text)
                        .padding()
                        .modifier(Regular(size: 14))
                        .background(Color("vivid_grey"))
                        .foregroundColor(.black)
                        .cornerRadius(10)
                case .review:
                    RoundedRectangle(cornerRadius: 15)
                        .foregroundColor(Color("chat_background"))
                        .overlay(
                            VStack(alignment:.leading){
                                Text("****")
                                    .modifier(Bold(size: 18))
                                    .padding(.bottom,24)

                                Text("\(formatCurrency(value:message.price))원")
                                    .modifier(Bold(size: 18))
                                Rectangle()
                                    .frame(width: 190, height:1)

                                Text("*** *****!")
                                    .modifier(Regular(size: 10))
                                Text("*** *** ****")
                                    .modifier(Regular(size: 10))
                                Text("***** *** ** ******.")
                                    .modifier(Regular(size: 10))
                                Spacer()
                                NavigationLink(destination: {
                                    ReviewMainView(modes: $appState.reviewModes, lobbyistId: $userInformationViewModel.userInformation.id)
                                }, label: {
                                    RoundedRectangle(cornerRadius: 5)
                                        .foregroundColor(.black)
                                        .overlay(
                                            Text("** ***")
                                                .modifier(Bold(size: 15))
                                                .foregroundColor(.white)
                                        )
                                        .frame(width: 190, height: 35)
                                })

                            }
                            .padding(22)
                        )

                        .frame(width: 231, height: 240)
                }

            } else {

                switch message.messageType {
                case .text:
                    WebImage(url: URL(string: "\(SHAREVAR.IMAGE_URL)/\(profileImage)"))
                        .resizable()
                        .scaledToFill()
                        .frame(width: 24, height: 24)
                        .clipped()
                        .cornerRadius(5)

                    Text(message.text)
                        .padding()
                        .modifier(Regular(size: 14))
                        .background(Color.white)
                        .foregroundColor(.black)
                        .cornerRadius(10)
                        .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.gray, lineWidth: 1))
                    Text("\(convertToHourAndMinute(dateString:message.createdTime))")
                        .modifier(Medium(size: 10))
                case .review:
                        EmptyView()
                }



                Spacer()
            }
        }
    }
}

2. 뷰 계층에서의 사용

  • ScrollViewReader를 쓰는 이유: ScrollViewReader는 스크롤 뷰 내의 특정 위치로 스크롤하는 기능을 제공합니다. 새 메시지가 추가될 때마다 스크롤뷰를 최하단으로 자동 이동시키기 위해 사용됩니다.
  • WebImage를 사용하는 이유: WebImage는 SDWebImageSwiftUI의 부분으로 웹에서 이미지를 비동기적으로 로드하고 캐시합니다. 이를 통해 프로필 이미지와 같은 웹 기반 이미지를 효과적으로 로드하고 표시할 수 있습니다.

질문이 있으시다면 댓글로 알려주시면 최대한 설명 해드릴 수 있도록 하겠습니다. 아 물론 코드 관련한 테클도 환영입니다.

반응형