Firestore에는 데이터에 변화가 생길 경우(추가, 수정, 삭제), 해당 데이터를 실시간으로 수신하는 기능이 있다.
addSnapshotListener를 이용해서 아주 간단한 채팅앱을 만들어 보았다. (작동만 하는 수준이다.)
Cloud Firestore로 실시간 업데이트 가져오기 | Firebase (google.com)
앱은 로그인 프래그먼트와 채팅 프래그먼트로 구성했다.
로그인 프래그먼트에서는 채팅방에서 사용할 닉네임을 정한다.
채팅 프래그먼트에서는 입력한 대화 내용이 Firestore에 문서로 저장되고, 추가된 문서들을 수신해 리사이클러 뷰에 추가한다.
데이터 구조
Firestore 데이터 구조는 다음과 같이 했다.
nickname: 글쓴이
contents: 대화 내용
time: 작성 시간
로그인 프래그먼트
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:focusable="true"
android:focusableInTouchMode="true">
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="사용할 닉네임을 입력해주세요"
app:layout_constraintBottom_toTopOf="@+id/et_nickname"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<EditText
android:id="@+id/et_nickname"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ems="10"
android:hint="닉네임 입력 (10자)"
android:inputType="textPersonName"
android:maxLength="10"
android:singleLine="true"
android:textAlignment="center"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/btn_enter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:text="입장"
android:textStyle="bold"
android:enabled="false"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/et_nickname" />
</androidx.constraintlayout.widget.ConstraintLayout>
class LoginFragment: Fragment() {
private var _binding: FragmentLoginBinding? = null
private val binding get() = _binding!!
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
_binding = FragmentLoginBinding.inflate(inflater, container, false)
val view = binding.root
// 닉네임이 공백일 경우 버튼 비활성화
binding.etNickname.addTextChangedListener { text ->
binding.btnEnter.isEnabled = text.toString().replace(" ", "") != ""
}
binding.btnEnter.setOnClickListener {
// 입력한 닉네임을 Bundle에 담아 ChatFragment로 보냄
val nickname = binding.etNickname.text.toString()
val bundle = Bundle()
bundle.putString("nickname", nickname)
// ChatFragment로 이동
(activity as MainActivity).replaceFragment(bundle)
}
return view
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
로그인 프래그먼트에서는 10자까지 입력할 수 있는 EditText를 배치하고, 공백이 아닐 경우에만 입장 버튼을 누를 수 있게 했다.
입장 버튼을 누르면 EditText에 담긴 닉네임을 Bundle에 담아 채팅 프래그먼트로 보낸다. 이 때 프래그먼트 교체시에는 메인 액티비티에서 작성한 replaceFragment 함수를 이용한다.
채팅 프래그먼트
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:background="#93A1CA"
app:layout_constraintBottom_toTopOf="@+id/constraintLayout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/constraintLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintVertical_weight="1">
<EditText
android:id="@+id/et_chatting"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:ems="10"
android:hint="내용 입력 (128자 제한)"
android:inputType="textMultiLine"
android:maxLength="128"
android:scrollHorizontally="false"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/btn_send"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/btn_send"
style="@android:style/Widget.Material.Light.Button.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:text="입력"
android:enabled="false"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
class ChatFragment: Fragment() {
private var _binding: FragmentChatBinding? = null
private val binding get() = _binding!!
private lateinit var currentUser: String // 현재 닉네임
private val db = FirebaseFirestore.getInstance() // Firestore 인스턴스
private lateinit var registration: ListenerRegistration // 문서 수신
private val chatList = arrayListOf<ChatLayout>() // 리사이클러 뷰 목록
private lateinit var adapter: ChatAdapter // 리사이클러 뷰 어댑터
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// LoginFragment 에서 입력한 닉네임을 가져옴
arguments?.let {
currentUser = it.getString("nickname").toString()
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
_binding = FragmentChatBinding.inflate(inflater, container, false)
val view = binding.root
Toast.makeText(context, "현재 닉네임은 ${currentUser}입니다.", Toast.LENGTH_SHORT).show()
// 리사이클러 뷰 설정
binding.rvList.layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
adapter = ChatAdapter(currentUser, chatList)
binding.rvList.adapter = adapter
// 채팅창이 공백일 경우 버튼 비활성화
binding.etChatting.addTextChangedListener { text ->
binding.btnSend.isEnabled = text.toString() != ""
}
// 입력 버튼
binding.btnSend.setOnClickListener {
// 입력 데이터
val data = hashMapOf(
"nickname" to currentUser,
"contents" to binding.etChatting.text.toString(),
"time" to Timestamp.now()
)
// Firestore에 기록
db.collection("Chat").add(data)
.addOnSuccessListener {
binding.etChatting.text.clear()
Log.w("ChatFragment", "Document added: $it")
}
.addOnFailureListener { e ->
Toast.makeText(context, "전송하는데 실패했습니다", Toast.LENGTH_SHORT).show()
Log.w("ChatFragment", "Error occurs: $e")
}
}
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
chatList.add(ChatLayout("알림", "$currentUser 닉네임으로 입장했습니다.", ""))
val enterTime = Date(System.currentTimeMillis())
registration = db.collection("Chat")
.orderBy("time", Query.Direction.DESCENDING)
.limit(1)
.addSnapshotListener { snapshots, e ->
// 오류 발생 시
if (e != null) {
Log.w("ChatFragment", "Listen failed: $e")
return@addSnapshotListener
}
// 원하지 않는 문서 무시
if (snapshots!!.metadata.isFromCache) return@addSnapshotListener
// 문서 수신
for (doc in snapshots.documentChanges) {
val timestamp = doc.document["time"] as Timestamp
// 문서가 추가될 경우 리사이클러 뷰에 추가
if (doc.type == DocumentChange.Type.ADDED && timestamp.toDate() > enterTime) {
val nickname = doc.document["nickname"].toString()
val contents = doc.document["contents"].toString()
// 타임스탬프를 한국 시간, 문자열로 바꿈
val sf = SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale.KOREA)
sf.timeZone = TimeZone.getTimeZone("Asia/Seoul")
val time = sf.format(timestamp.toDate())
val item = ChatLayout(nickname, contents, time)
chatList.add(item)
}
adapter.notifyDataSetChanged()
}
}
}
override fun onDestroyView() {
super.onDestroyView()
registration.remove()
_binding = null
}
}
원하지 않는 문서 무시는 채팅방 입장 시 데이터베이스에 추가 되어있던 문서 & 앱 재실행 시 이전 캐시에 남아있던 문서들이 리사이클러 뷰에 추가되는 것을 방지하기 위한 것이다.
스냅샷 리스너를 통해 수신된 문서들은 documentChanges에 추가된다. 이 때 특정 변경사항만 이용하려면 조건문을 이용해 문서의 type을 비교한다.
DocumentChange.Type.ADDED: 추가된 문서
DocumentChange.Type.MODIFIED: 수정된 문서
DocumentChange.Type.REMOVED: 삭제된 문서
※ 참고: 스냅샷 리스너를 추가하면 처음에 Firestore에 들어있던 문서들이 ADDED 타입으로 documentChanges에 추가된다.
리사이클러 뷰 레이아웃, 어댑터
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/chat_card_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="4dp"
app:cardCornerRadius="4dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/chat_tv_nickname"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:text="닉네임"
android:textColor="?android:attr/textColorPrimary"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/chat_tv_contents"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:text="채팅 내용"
android:textColor="?android:attr/textColorPrimary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/chat_tv_nickname"
app:layout_constraintTop_toBottomOf="@+id/chat_tv_nickname" />
<TextView
android:id="@+id/chat_tv_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginBottom="4dp"
android:text="작성시간"
android:textSize="12sp"
app:layout_constraintBottom_toTopOf="@+id/chat_tv_contents"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
// 리사이클러 뷰 아이템 클래스
class ChatLayout(val nickname: String, val contents: String, val time: String)
// 리사이클러 뷰 어댑터 클래스
class ChatAdapter(val currentUser: String, val itemList: ArrayList<ChatLayout>): RecyclerView.Adapter<ChatAdapter.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChatAdapter.ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.chat_layout, parent, false)
return ViewHolder(view)
}
override fun getItemCount(): Int {
return itemList.size
}
override fun onBindViewHolder(holder: ChatAdapter.ViewHolder, position: Int) {
// 현재 닉네임과 글쓴이의 닉네임이 같을 경우 배경을 노란색으로 변경
if (currentUser == itemList[position].nickname) {
holder.card.setCardBackgroundColor(Color.parseColor("#FFF176"))
}
holder.nickname.text = itemList[position].nickname
holder.contents.text = itemList[position].contents
holder.time.text = itemList[position].time
}
class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) {
val card: CardView = itemView.findViewById(R.id.chat_card_view)
val nickname: TextView = itemView.findViewById(R.id.chat_tv_nickname)
val contents: TextView = itemView.findViewById(R.id.chat_tv_contents)
val time: TextView = itemView.findViewById(R.id.chat_tv_time)
}
}
어댑터에서는 현재 닉네임을 매개변수로 받은 후, 아이템을 등록할 때 아이템의 닉네임과 비교해서 같을 경우 배경색이 노란색이 되도록 작성했다.
메인 액티비티
<?xml version="1.0" encoding="utf-8"?>
<androidx.appcompat.widget.LinearLayoutCompat
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<!-- 프래그먼트를 띄울 레이아웃 -->
<FrameLayout
android:id="@+id/layout_frame"
android:layout_width="match_parent"
android:layout_height="match_parent">
</FrameLayout>
</androidx.appcompat.widget.LinearLayoutCompat>
메인 액티비티에는 프래그먼트를 띄우는데 사용할 FrameLayout 하나만 추가했다.
class MainActivity : AppCompatActivity() {
private lateinit var binding : ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
val view = binding.root
setContentView(view)
// 앱 구동시 LoginFragment 표시
supportFragmentManager.beginTransaction()
.replace(R.id.layout_frame, LoginFragment())
.commit()
}
// ChatFragment로 프래그먼트 교체 (LoginFragment에서 호출할 예정)
fun replaceFragment(bundle: Bundle) {
val destination = ChatFragment()
destination.arguments = bundle // 닉네임을 받아옴
supportFragmentManager.beginTransaction()
.replace(R.id.layout_frame, destination)
.commit()
}
}
replaceFragment 함수는 닉네임이 남긴 Bundle을 매개변수로 받아 ChatFragment의 인자로 보내고, 프래그먼트를 교체하는 기능을 한다. LoginFragment가 직접 프래그먼트를 교체할 수 없으므로 메인 액티비티에 있는 이 함수를 참조해서 호출한다.
실행 결과
'프로그래밍 > 안드로이드' 카테고리의 다른 글
[Android/KakaoAPI] 카카오 지도에서 현재 위치 추적 (3) | 2021.01.12 |
---|---|
[Android/KakaoAPI] 안드로이드 앱에서 카카오 지도 사용하기 (0) | 2021.01.10 |
[Android/Firebase] Firestore 읽기, 쓰기 (0) | 2021.01.05 |
[Android/Firebase] 안드로이드 프로젝트에 Firestore 연동 (0) | 2021.01.03 |
[Android/Kotlin] DialogFragment 커스텀 대화상자 만들기 (0) | 2021.01.02 |