목차
지난번 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의 부분으로 웹에서 이미지를 비동기적으로 로드하고 캐시합니다. 이를 통해 프로필 이미지와 같은 웹 기반 이미지를 효과적으로 로드하고 표시할 수 있습니다.
질문이 있으시다면 댓글로 알려주시면 최대한 설명 해드릴 수 있도록 하겠습니다. 아 물론 코드 관련한 테클도 환영입니다.
반응형
'iOS' 카테고리의 다른 글
[Swift] Swift에서 Equatable을 이해하고 적용하기 (0) | 2023.10.26 |
---|---|
[Swift] Combine + Moya + Alamofire 조합해서 Request Response 받아보기 (0) | 2023.10.25 |
[iOS/SwiftUI] @EnvironmentObject와 Singleton의 차이점 (2) | 2023.10.11 |
[iOS/SwiftUI] SwiftUI에서 @EnvironmentObject 설명 및 사용법 (0) | 2023.10.11 |
[iOS/SwiftUI] SwiftUI 에서 SocketiO 사용하기 (1) (0) | 2023.09.24 |