Toast와 거의 동일하지만 생김새가 다르다.
예시
Snackbar.make(view,"로그인 후 사용해주세요", Snackbar.LENGTH_LONG).show()
결과 화면
웹주소를 의미하며, 해당 리소스가 어디에 있는지를 나타내준다.
인터넷에서 웹 페이지, 이미지, 비디오 등 리소스의 위치를 가리키는 문자열이다.
URI의 서브넷으로 URI의 역할에서 웹주소에 특화되어 있는 영역이다.
예를 들어
나의 인형의 이름이 "미스 포츈"이라고 한다면,
이 "미스 포츈"이라는 명칭은 식별자(Identifier)로써
나의 특정 인형을 특정하도록 해주므로 URI이다.
하지만 "미스 포츈" 만으로는 이 인형의 위치를 식별할 수 없으므로 URL은 될 수 없다.
반면에 "미스 포츈"의 위치인 "서울 동작구 사당동"은 "미스 포츈"의 위치를 나타내므로
URL이자 URI이다.
즉, URI는 식별자이고,
URL은 주소( 위치정보 )를 통한 식별자이다.
아래의 두 주소는 모두 www.homepage.com 웹서버 내부의 index.html을 가리키고 있다.
첫번째 주소의 경우, ( www.homepage.com/index.html )
웹서버의 실제 주소를 나타내므로 URI이자 URL로 볼 수 있다.
-> URI : O , URL : O
두번째 주소의 경우, ( www.homepage.com/index )
실제로 index라는 파일이 해당 웹서버 내에 존재하지 않으므로 URL이라고 볼 수 없다.
하지만 웹서버 내에서 이를 처리하여 결과적으로 index.html을 식별해주기 때문에 URI라고 볼 수 있다.
-> URI : O , URL : X
--> 이미지 파일과 같이 파일, 동영상 파일등을 저장할 수 있는 저장고
해당 글 참조 ( 프로젝트 등록 ) : https://velog.io/@odesay97/%EC%95%B1-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-09-1-%ED%91%B8%EC%8B%9C-%EC%95%8C%EB%A6%BC-%EC%88%98%EC%8B%A0%EA%B8%B0-firebase
프로잭트에서 앱 수준의 gradle에 Firebase Storage에 대한 의존성 추가
implementation 'com.google.firebase:firebase-storage-ktx'
ㅡ 이 코드 아래에 추가해줘야 함
예시)
private val storage: FirebaseStorage by lazy {
Firebase.storage
}
private var selectedUri: Uri? = null
......
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_add_article)
// 이미지 Url
// 앱에서는 일반적으로 사진첩에서 사용자가 선택한 이미지의 url를 가져옴
selectedUri = "https://firebasestorage.googleapis.com/v0/b/aop-part3-chapter14.appspot.com/o/article%2Fphoto%2F1647216112323.png?alt=media&token=ef33130f-3650-4a1b-a78d-68c5287d1929"
val PhotoUri = selectedUri
// 아래에서 구현한 메소드를 사용하는 부분
uploadPhoto(
PhotoUri,
successHandler = { uri ->
uploadArticle(sellerId, title, price, uri)
// Realtime Database에 데이터를 업로드하는 메소드를 구성한 것
},
errorHandler = {
Toast.makeText(this, "사진 업로드에 실패했습니다.", Toast.LENGTH_SHORT).show()
}
)
}
......
// firebase Storage에 데이터 넣기 + 데이터 가져오기 를 구현한 메소드 생성
private fun uploadPhoto(uri: Uri, successHandler: (String) -> Unit, errorHandler: () -> Unit) {
// 이미지 파일의 이름에 대한 형식설정( 이름이 겹치면 안되므로 현재 시간을 이름으로 함 )
val fileName = "${System.currentTimeMillis()}.png"
// 데이터 넣기
storage.reference.child("article/photo").child(fileName)
.putFile(uri)
.addOnCompleteListener {
if (it.isSuccessful) {
// 데이터가 잘 들어갔다면
// 데이터를 가져오기
storage.reference.child("article/photo").child(fileName)
.downloadUrl
.addOnSuccessListener { uri ->
successHandler(uri.toString())
}
.addOnFailureListener {
errorHandler()
}
} else {
errorHandler()
}
}
}
private val storage: FirebaseStorage by lazy {
Firebase.storage
}
private var selectedUri: Uri? = null
// 이미지 Url
// 앱에서는 일반적으로 사진첩에서 사용자가 선택한 이미지의 url를 가져옴
selectedUri = "https://firebasestorage.googleapis.com/v0/b/aop-part3-chapter14.appspot.com/o/article%2Fphoto%2F1647216112323.png?alt=media&token=ef33130f-3650-4a1b-a78d-68c5287d1929"
// 이미지 파일의 이름에 대한 형식설정( 이름이 겹치면 안되므로 현재 시간을 이름으로 함 )
val fileName = "${System.currentTimeMillis()}.png"
storage.reference.child("article/photo").child(fileName)
.putFile(selectedUri)
.addOnCompleteListener { task ->
if (task.isSuccessful) {
// Firebase Storage에 데이터가 잘 들어갔다면
} else {
// Firebase Storage에 데이터가 잘 들어가지 못했다면
}
이 부분에서 이미지 파일이나 동영상 파일을 담기 위한 Uri형 변수를 선언해준다.
일반적으로 동영상, 이미지와 같은 리소스들은 Uri에 담아서 전달하고 전달받는다.
( URL이 아닌 URI임 -> Uri가 무엇인지는 위에 설명해 놓음 )
private var selectedUri: Uri? = null
이 부분에서 Firebase Storage에 넣기 위한 이미지 Uri를 받는다.
( 일반적으로 사용자가 사진첩에서 이미지 Uri를 선택하여 그 내용이 들어가도록 코딩한다. )
--> 사용자가 사진첩에서 이미지를 선택하도록 하는 부분은 따로 아래에 정리해 놓았음
private var selectedUri: Uri? = null
selectedUri = "https://firebasestorage.googleapis.com/v0/b/aop-part3-chapter14.appspot.com/o/article%2Fphoto%2F1647216112323.png?alt=media&token=ef33130f-3650-4a1b-a78d-68c5287d1929"
이 부분에서 Firebase Storage에 넣을 이미지 파일의 이름을 설정함
( 이미지의 이름이 중복되면 덮어쓰기 되므로 주의하여 설정 )
val fileName = "${System.currentTimeMillis()}.png"
여기서는 현재 시간을 이름으로 함 ( 이름이 겹치면 안되므로 )
이름에 확장자가 포함되어 있어야 함 ( 주의 !! )
이 부분에서 Firebase Storage에 Uri에 있는 데이터를 넣음
selectedUri = "Storage에 넣을 이미지의 Uri"
val fileName = "${System.currentTimeMillis()}.png"
storage.reference.child("article/photo").child(fileName)
.putFile(selectedUri)
DB의 최상위 항목에 접근하는 부분까지는 Realtime Database와 동일
--> "storage.reference"
DB를 탐색하는 부분은 파일탐색기와 비슷한 방식을 사용
-> 예를들어, child("article/photo")이라면 article폴더 안의 photo폴더 안으로 이동
이후 child(파일 이름)를 통해 해당 영역에 파일을 만들어 줄 수 있음 ( 적합한 확장자를 포함시킬 것 )
-> "child(이미지파일이름.png)"
이 부분에서 putFile(파일URI) 메소드를 사용하여 Storage에 데이터를 넣음
storage.reference.child("article/photo").child(fileName)
.putFile(selectedUri)
이 부분에서 addOnCompliteListener를 통해 Firebase Storage에 데이터가 들어간 이후에 대한 처리를 해줌
storage.reference.child("article/photo").child(fileName)
.putFile(selectedUri)
.addOnCompleteListener { task ->
if (task.isSuccessful) {
// Firebase Storage에 데이터가 잘 들어갔다면
} else {
// Firebase Storage에 데이터가 잘 들어가지 못했다면
}
addOnCompleteListener()는 작업의 성공여부와 상관없이 작업이 완료되면 호출되는 메소드임
putFile() 메소드는 Storage에 데이터를 넣는 작업을 완료한 후,
작업 결과를 나타내기 위한 변수를 반환하는데,
addOnCompleteListener는 그 변수를 파타미터로 받아 람다를 실행함
그 파라미터의 isSuccessful변수에
해당 작업이 성공했는지 실패있는지 여부가 Boolean타입으로 할당되어 있음
storage.reference.child("article/photo").child(fileName)
.downloadUrl
.addOnSuccessListener { uri ->
successHandler(uri.toString())
}
.addOnFailureListener {
errorHandler()
}
이 부분에서 Firebase Storage에서 원하는 이미지 항목에 접근,
storage.reference.child("article/photo").child(fileName)
이 부분에서 해당 이미지의 Url을 가져옴 ( URI가 아니라 URL임 -> Storage에서 참조해오는 영역이기 때문 )
storage.reference.child("article/photo").child(fileName)
.downloadUrl
이 부분에서 Firebase Storage에서 데이터를 가져오는 작업의 성공 여부에 대한 처리를 해줌
storage.reference.child("article/photo").child(fileName)
.downloadUrl
.addOnSuccessListener { uri ->
successHandler(uri.toString())
}
.addOnFailureListener {
errorHandler()
}
데이터를 가져오는 작업이 성공해서 downloadUrl에 URL이 들어있다면
addOnSuccessListener의 람다함수를 열어서
해당 URL을 람다함수의 파라미터로 하여 람다함수 실행
( 파일을 불러온 것에 성공했으므로 이에 대한 처리를 해줌 )
데이터를 가져오는 작업이 실패해서 downloadUrl에 URL이 들어있지 않다면
addOnFailureListener의 람다함수를 열어서
실패에 대한 처리를 해줌
만약에 작업이 2초 이상 걸리는 등의 즉각적인 반응이 오지 않는 작업이라면
사용자가 작업이 진행중이라는 것을 인지할 수 있도록 ProgressBar를 사용해서 그것을 표현해줘야 한다.
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:visibility="gone"
tool:visibility="visible"/>
Firebase Realtime Database : https://velog.io/@odesay97/%EC%95%B1-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-13-1-%ED%8B%B4%EB%8D%94-%EC%95%B1-
package com.example.aop_part3_chapter14.chatlist
data class ArticleModel(
val sellerId: String,
val title: String,
val createdAt: Long,
val price: String,
val imageUrl: String
){
constructor(): this("","",0,"","")
}
......
){
constructor(): this("","",0,"","")
}
private lateinit var articleDB: DatabaseReference
articleDB = Firebase.database.reference.child(DB_ARTICLES)
// DB에 넣기 위한 Data Model 구성
val articleModel = ArticleModel(
sellerId = auth.currentUser!!.uid,
title = "타이틀",
createdAt = 10000,
price = "15000원",
imageUrl = ""
)
// Data Model 그대로 DB에 넣기
articleDB
.push()
.setValue(articleModel)
이 부분에서 Firebase DB에서 원하는 위치까지 접근
private lateinit var articleDB: DatabaseReference
articleDB = Firebase.database.reference.child(DB_ARTICLES)
이 부분에서 DB에 넣기 위한 데이터 모델을 구성
val articleModel = ArticleModel(
sellerId = auth.currentUser!!.uid,
title = "타이틀",
createdAt = 10000,
price = "15000원",
imageUrl = ""
)
이 부분에서 데이터 모델을 DB에 넣고 있음
articleDB
.push()
.setValue(articleModel)
해당 코드를 통해 DB에 통채로 들어간 데이터 모델 예시
각 데이터 모델은 각자의 식별 Key를 가지고 DB에 들어가게 되며,
위의 예시와 같이 해당 식별 Key의 Value로 데이터 모델에 담긴 데이터가 들어가게 됨
따라서 데이터 모델을 통채로 DB에 넣는 방식을 사용한 경우,
DB에서 데이터를 꺼낼 때도 데이터 모델을 통채로 꺼내서
데이터 모델에서 필요한 데이터를 뽑아내는 방식으로 사용하는 것을 추천함
private lateinit var articleDB: DatabaseReference
articleDB = Firebase.database.reference.child(DB_ARTICLES)
private val articleList = mutableListOf<ArticleModel>()
private val listener = object : ChildEventListener {
override fun onChildAdded(snapshot: DataSnapshot, previousChildName: String?) {
val articleModel = snapshot.getValue(ArticleModel::class.java)
articleModel ?: return
articleList.add(articleModel)
}
override fun onChildChanged(snapshot: DataSnapshot, previousChildName: String?) {}
override fun onChildRemoved(snapshot: DataSnapshot) {}
override fun onChildMoved(snapshot: DataSnapshot, previousChildName: String?) {}
override fun onCancelled(error: DatabaseError) {}
}
// onViewCreated()가 될 때마다(즉, Fragment가 생성될 때마다) 이벤트 리스너를 붙여줌
articleDB.addChildEventListener(listener)
private lateinit var articleDB: DatabaseReference
articleDB = Firebase.database.reference.child(DB_ARTICLES)
private val articleList = mutableListOf<ArticleModel>()
private val listener = object : ChildEventListener {
override fun onChildAdded(snapshot: DataSnapshot, previousChildName: String?) {
val articleModel = snapshot.getValue(ArticleModel::class.java)
articleModel ?: return
articleList.add(articleModel)
}
override fun onChildChanged(snapshot: DataSnapshot, previousChildName: String?) {}
override fun onChildRemoved(snapshot: DataSnapshot) {}
override fun onChildMoved(snapshot: DataSnapshot, previousChildName: String?) {}
override fun onCancelled(error: DatabaseError) {}
}
// onViewCreated()가 될 때마다(즉, Fragment가 생성될 때마다) 이벤트 리스너를 붙여줌
articleDB.addChildEventListener(listener)
ArticleModel을 통으로 DB에 올려서 저장했다는 전제가 있기 때문에 위와 같이 DB에서 받아오는 것도 ArticleModel 통으로 가능한 것이다.
이 부분에서 ChildEventListener를 무명클래스로 구현
private val listener = object : ChildEventListener {
override fun onChildAdded(snapshot: DataSnapshot, previousChildName: String?) {
......
이 부분에서 해당 위치( 해당 리스너를 설정한 DB의 위치 )에 있는 데이터 모델들을 가져와서 리스트에 담음
val articleModel = snapshot.getValue(ArticleModel::class.java)
articleModel ?: return
articleList.add(articleModel)
onChildAdded()는 해당 위치( 해당 리스너를 설정한 DB의 위치 )에 있는 데이터가 추가되면 추가된 데이터를 가져옴
그런데 실행된 최초에는 해당 위치에 데이터가 없는 상태라고 인식한 상황이므로,
해당 위치에 있는 데이터를 각각 하나씩 모두 가져오게 된다.
( 기존에 있던 데이터를 새롭게 추가된 데이터라고 인식 )
-> 즉, 해당 위치에 데이터가 5개라면 최초에 onChildAdded()가 5번 호출되면서 각각 데이터를 하나씩 가져옴
만약에 데이터가 비어있다면 해당 onChildAdded() 메소드는 그대로 반환
articleModel ?: return
제대로 데이터가 들어있다면 데이터 모델을 넣는 리스트에 추가
articleList.add(articleModel)
이 부분에서 addChildEventListener()를 통해 해당 DB 위치에 리스너를 설정함
articleDB.addChildEventListener(listener)
private val listener = object : ValueEventListener{
override fun onDataChange(snapshot: DataSnapshot) {
snapshot.children.forEach{
val articleModel = it.getValue(ArticleModel::class.java)
articleModel ?: return
articleList.add(articleModel)
}
}
override fun onCancelled(error: DatabaseError) {}
}
articleDB.addListenerForSingleValueEvent(listener)
ArticleModel을 통으로 DB에 올려서 저장했다는 전제가 있기 때문에 위와 같이 DB에서 받아오는 것도 ArticleModel 통으로 가능한 것이다.
SingleValueEvent로 불러올 경우에는 해당 리스너가 SingleValue로 데이터를 가져오므로
불러올 해당 DB 영역에 존재하는 데이터 모델들을 담은 하나의 List로 들어오게 된다.
즉, 위의 ChildEventListener에서는 onChildAdded가 여러번 실행되면서 하나씩 데이터 모델을 가져왔지만,
이번의 SingleValueEvent에서는 SingleValue( 하나의 데이터 )만을 가져오므로 onDataChange()가 한번만 실행되면서
해당 위치에 존재하는 데이터 모델들을 하나의 리스트에 담아서 가져오게 된다.
-> 따라서 forEach로 들어온 List에서 데이터 모델을 하나씩 분리하여 가져와야 한다.
이 부분에서 해당 위치( 해당 리스너를 설정한 DB의 위치 )에 있는 데이터 모델들을 가져와서 리스트에 담음
snapshot.children.forEach{
val articleModel = it.getValue(ArticleModel::class.java)
articleModel ?: return
articleList.add(articleModel)
}
만약에 데이터가 비어있다면 해당 forEach 반복은 그대로 반환 ( 다음 반복이 시작됨 )
articleModel ?: return
제대로 데이터가 들어있다면 데이터 모델을 넣는 리스트에 추가
articleList.add(articleModel)
이 부분에서 addListenerForSingleValueEvent()를 통해 해당 DB 위치에 리스너를 설정함
articleDB.addListenerForSingleValueEvent(listener)
Firebase Authentication : https://velog.io/@odesay97/%EC%95%B1-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-13-1-%ED%8B%B4%EB%8D%94-%EC%95%B1-
앱 차원에서 Firebase Authentication 기능을 통해 로그인 할 경우,
해당 앱의 Firebase.auth에 로그인 정보가 할당되어 있게 된다.
이것은 액티비티 차원이 아니라 앱 차원에서 라이브러리에 할당되는 데이터이기 때문에
로그인 여부에 따른 처리를 해주고 싶은 경우
다른 것 신경쓸 것 없이 Firebase.auth의 currentUser 가 null인지 아닌지 여부만 확인해주면 된다.
private val auth: FirebaseAuth by lazy {
Firebase.auth
}
if(auth.currentUser == null)
// 로그인 되어있지 않을 때 실행
if(auth.currentUser != null)
// 로그인 되어있을 때 실행
Authentication은 로그인인 만큼 Authentication을 통해 로그인을 했을 경우,
다른 DB( Realtime Database, Storage 등등 )에 대한 권한을 주는 것이 가능하다.
정확히는 해당 DB의 규칙 영역에서 설정하는 것인데,
예를 들어 Firebase Storage에서 Auth의 여부에 따른 권한을 부여하기 위해서는
아래와 같이 Firebase Storage의 규칙을 수정해주면 된다.
( 정확히는 그대로 하지 말고, 아래 참고해서 유동적으로 수정해줄 것 )
대략적인 원리에 대해 설명하자면,
앱 차원에서 Firebase Authentication 기능을 통해 로그인 할 경우,
해당 앱의 Firebase.auth에 로그인 정보가 할당되어 있게 된다.
( 로그아웃하면 null로 재할당됨 -> 즉, Firebase.auth엔 현재 계정의 정보가 들어가게 되는 것 )
이것을 전제로 DB에 대해 read나 write에 대한 request가 오면
이 요청의 request.auth가 존재한다면( 즉, 해당 request의 요청자가 Firebase Authentication에 로그인 되어있다면 )
read나 write할 권한을 준다는 것이다.
이런 부분을 나타내는 코드가 다음과 같다.
allow write: if request.auth != null
// 해당 DB에 대한 작업의 request에 대해 request.auth가 있다면 ( request한 요청자가 로그인되어 있는 상태라면 )
// write 작업을 허락한다.
allow read: if request.auth != null
// 해당 DB에 대한 작업의 request에 대해 request.auth가 있다면 ( request한 요청자가 로그인되어 있는 상태라면 )
// read 작업을 허락한다.
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/addFloatingButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:backgroundTint="@color/orange"
android:src="@drawable/ic_baseline_add_24"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:tint="@color/white" />
material 계열의 컴포넌트는 배경색을 background가 아니라 backgroundTint에다가 줘야 적용된다.
--> Button처럼 Theme에서 적용되는 색이기 때문
내부에 들어갈 Vector Drawable의 색은 FloatingActionButton에서 app:tint 속성의 값을 바꿔주면 된다.
--> Vector Drawable 파일에서 색을 바꿔도 적용되지 않음
RecyclerView : https://velog.io/@odesay97/%EC%95%B1-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-12-1-%EB%8F%84%EC%84%9C-%EB%A6%AC%EB%B7%B0-%EC%95%B1
ViewBinding : https://velog.io/@odesay97/%EC%95%B1-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-12-1-%EB%8F%84%EC%84%9C-%EB%A6%AC%EB%B7%B0-%EC%95%B1
res폴더 내에 drawable폴더 안에 selector 태그를 구현할 xml 파일을 생성
drawable폴더에 xml파일로 selector를 만들어서, 버튼이 선택되었을 때와 선택지지 않았을 때의 색을 정리
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/black" android:state_checked="true"/>
<item android:color="@color/gray_cc" android:state_checked="false"/>
</selector>
버튼이 선택 상태일 경우, 버튼의 색을 black으로
버튼이 선택 상태가 아닌 경우, 버튼의 색을 gray_cc로
--> 즉, 선택되지 않은 탭의 icon과 title은 gray_cc 색으로 표현되며
선택된 탭의 icon과 title은 black색으로 표현된다.
전체 Activity의 레이아웃 xml 파일
<?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"
tools:context=".MainActivity">
<Button
android:id="@+id/bottomNavigationView"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:itemIconTint="@drawable/selector_menu_color"
app:itemRippleColor="@null"
app:itemTextColor="@color/black"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
itemRippleColor를 @null로 줘서 클릭했을 때 나타나는 Ripple효과를 없앰
--> 클릭 했을 때 해당 탭의 배경에 대한 애니메이션 효과를 없애버림
app:itemIconTint 속성에 1-2번에서 Button의 색깔을 구성한 xml파일을 참조해서 넣음
( 위에서 res폴더 안에 drawable 폴더 안에 만든 selector태그를 구현한 BottomNavigationView 탭 색깔 xml 파일 )
이번과 같이 Firebase의 Realtime Database를 사용하여 채팅 기능을 구현하면,
채팅의 실시간 반응을 포함한 대부분의 에로사항들은 해결이 되기 때문에 편리하다.
만약에 나중에라도 Firebase의 Realtime Database를 사용하지 않고 직접 서버를 운용하여 채팅기능을 구현한다고 한다면
실시간으로 채팅이 올라오는 것을 구현하기 위해서
웹소켓를 통해 구현하거나 혹은,
Retrofit에서 http로 가져오는 방식으로 api처럼 데이터를 가져오고 싶다면,
pulling방식으로 10초에 한번씩 서버에 새로운 데이터가 있는지 확인하는 식의
구현하기 까다로운 부분들을 처리해줘야 한다.
채팅의 경우 기본적으로 복잡한 영역들이 많으므로 이번의 설명만으로 이해시키기 힘들다.
그냥 맨 아래에 이번 앱을 천천히 보고 인식하는 것을 추천
이 부분에서는 핵심적인 부분들만 단락적으로 다룰 것이다.
요약)
RecyclerView를 사용하여 채팅 리스트 구현
채팅 리스트를 클릭하면 채팅방으로 이동하도록 구현
DB를 적절히 설계하여 채팅 리스트를 담아야 함
예시) Fragment에서 구성한 채팅 리스트
class ChatListFragment:Fragment(R.layout.fragment_chatlist) {
private var binding: FragmentChatlistBinding? = null
private lateinit var chatListAdapter: ChatListAdapter
private val chatRoomList = mutableListOf<ChatListItem>()
private lateinit var chatDB: DatabaseReference
private val auth: FirebaseAuth by lazy {
Firebase.auth
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val fragmentChatlistBinding = FragmentChatlistBinding.bind(view)
binding = fragmentChatlistBinding
chatListAdapter = ChatListAdapter(onItemClicked = { ChatRoom ->
// todo 채팅방으로 이동하는 코드
val intent = Intent(requireContext(),ChatRoomActivity::class.java)
intent.putExtra("chatKey", ChatRoom.key)
startActivity(intent)
})
chatRoomList.clear()
fragmentChatlistBinding.chatListRecyclerView.adapter = chatListAdapter
fragmentChatlistBinding.chatListRecyclerView.layoutManager = LinearLayoutManager(context)
if (auth.currentUser == null){
return
}
chatDB = Firebase.database.reference.child(DB_USERS).child(auth.currentUser!!.uid).child(CHILD_CHAT)
// todo Data model 통채로 DB에 넣었던 것을 통채로 가져오는 부분 ( DB에 넣을 때 Data model 통채로 넣었기에 통채로 가져오는 것이 가능한 것임 )
// todo SingleValueEvent로 Data model을 불러올 경우에는 해당 리스너가 SingleValue로 데이터를 가져오므로 불러올 영역에 존재하는 Data model들을 담은 하나의 List로 들어오게 된다.
// todo 따라서 forEach로 들어온 List에서 Data model을 하나씩 분리하여 가져와야 한다.
chatDB.addListenerForSingleValueEvent(object : ValueEventListener{
override fun onDataChange(snapshot: DataSnapshot) {
// todo 서버에서 데이터를 가져오는 것에 성공하면 호출
// snapshot.children에 Data model들을 담은 하나의 리스트가 내려옴
// 이 리스트에서 Data model들을 하나씩 분리하는 작업이 필요 ( forEach )
snapshot.children.forEach{
val model = it.getValue(ChatListItem::class.java)
model ?: return
chatRoomList.add(model)
}
chatListAdapter.submitList(chatRoomList)
chatListAdapter.notifyDataSetChanged()
}
override fun onCancelled(error: DatabaseError) {
// TODO 서버에서 데이터를 가져오는 것에 실패했을 경우 호출
}
})
}
override fun onResume() {
super.onResume()
chatListAdapter.notifyDataSetChanged()
}
}
이 부분에서 채팅방 리스트를 클릭하면 채팅방으로 이동하도록 RecyclerView에
리스너를 설정
chatListAdapter = ChatListAdapter(onItemClicked = { ChatRoom ->
// todo 채팅방으로 이동하는 코드
val intent = Intent(requireContext(),ChatRoomActivity::class.java)
intent.putExtra("chatKey", ChatRoom.key)
startActivity(intent)
})
요약)
RecyclerView로 채팅창을 구현
채팅 리스트에서 채팅방 id를 intent로 받아서
DB에서 해당 채팅방의 데이터를 가져와 RecyclerView에 넣음
채팅을 올릴 때마다 채팅을 구성한 data model을 DB에 올리고,
RecyclerView에 데이터를 넣어서 다시 초기화
adapter.notifyDataSetChanged()
예시) Activity에서 구성한 채팅방
class ChatRoomActivity : AppCompatActivity() {
private val auth: FirebaseAuth by lazy {
Firebase.auth
}
private val chatList = mutableListOf<ChatItem>()
private val adapter = ChatItemAdapter()
private var chatDB: DatabaseReference? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_chatroom)
val chatkey = intent.getLongExtra("chatKey", -1)
chatDB = Firebase.database.reference.child(DB_CHATS).child(chatkey.toString())
chatDB?.addChildEventListener(object : ChildEventListener {
override fun onChildAdded(snapshot: DataSnapshot, previousChildName: String?) {
val chatItem = snapshot.getValue(ChatItem::class.java)
chatItem ?: return
chatList.add(chatItem)
adapter.submitList(chatList)
adapter.notifyDataSetChanged()
}
override fun onChildChanged(snapshot: DataSnapshot, previousChildName: String?) {}
override fun onChildRemoved(snapshot: DataSnapshot) {}
override fun onChildMoved(snapshot: DataSnapshot, previousChildName: String?) {}
override fun onCancelled(error: DatabaseError) {}
})
findViewById<RecyclerView>(R.id.chatRecyclerView).adapter = adapter
findViewById<RecyclerView>(R.id.chatRecyclerView).layoutManager = LinearLayoutManager(this)
// 현재시간 넣기
val t_date = Date(System.currentTimeMillis())
val nowTime = SimpleDateFormat("yyyy-MM-dd kk:mm", Locale("ko", "KR"))
.format(t_date)
findViewById<Button>(R.id.sendButton).setOnClickListener {
val chatItem = ChatItem(
senderId = auth.currentUser?.uid.orEmpty(),
message = findViewById<EditText>(R.id.messageEditText).text.toString(),
time = nowTime
)
chatDB?.push()?.setValue(chatItem)
}
}
}
BottomNavigationView는 3가지의 합작으로 만들어진다.
BottomNavigationView가 들어갈 전체 Activity
BottomNavigationView의 메뉴 -> ( 위의 예시에서 홈 / 채팅 / 나의 정보 )
BottomNavigationView에서 선택된 메뉴에 따라 화면에 나타날 Fragment 화면
추가)
4. 탭이 선택되었을 때와 선택되지 않았을 때의 탭 색깔 설정
res 폴더 내부에 menu폴더를 만들어서 menu태그를 구현할 xml 파일로 BottomNavigationView에 들어갈 탭들의 리스트 구성
탭은 item태그로 만들면 되며 그 속성은 다음과 같다.
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/home"
android:icon="@drawable/ic_baseline_home_24"
android:title="@string/home" />
</menu>
res폴더 내에 drawable폴더 안에 selector 태그를 구현할 xml 파일을 생성
drawable폴더에 xml파일로 selector를 만들어서, 네이게이션바의 각 탭들이 선택되었을 때와 선택지지 않았을 때의 색을 정리
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/black" android:state_checked="true"/>
<item android:color="@color/gray_cc" android:state_checked="false"/>
</selector>
탭이 선택 상태일 경우, 탭의 색을 black으로
탭이 선택 상태가 아닌 경우, 탭의 색을 gray_cc로
--> 즉, 선택되지 않은 탭의 icon과 title은 gray_cc 색으로 표현되며
선택된 탭의 icon과 title은 black색으로 표현된다.
전체 Activity의 레이아웃 xml 파일
<?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"
tools:context=".MainActivity">
<!-- Fragment가 붙기 위한 레이아웃-->
<FrameLayout
android:id="@+id/fragmentContainer"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@+id/bottomNavigationView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottomNavigationView"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:itemIconTint="@drawable/selector_menu_color"
app:itemRippleColor="@null"
app:itemTextColor="@color/black"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:menu="@menu/bottom_navigation_menu" />
</androidx.constraintlayout.widget.ConstraintLayout>
FrameLayout을 통해 BottomNavigationView의 메뉴 선택에 따라 화면에 변경되어 나타날 Fragment의 영역을 지정해줌
BottomNavigationView 태그를 구성한다.
BottomNavigationView 태그를 통해 BottomNavigationView 탭의 위치를 설정한다.
( 위로 붙이면 위에 탭들이 나타나고, 아래에 붙이면 아래에 탭들이 나타나게 되는 등등 )
app:menu 속성에 1번에서 탭 리스트를 구성한 xml파일을 참조해서 넣음
( 위에서 res폴더 안에 menu 폴더 안에 만든 menu태그를 구현한 BottomNavigationView 탭 리스트 xml 파일 )
itemRippleColor를 @null로 줘서 클릭했을 때 나타나는 Ripple효과를 없앰
--> 클릭 했을 때 해당 탭의 배경에 대한 애니메이션 효과를 없애버림
app:itemIconTint 속성에 1-2번에서 탭의 색깔을 구성한 xml파일을 참조해서 넣음
( 위에서 res폴더 안에 menu 폴더 안에 만든 selector태그를 구현한 BottomNavigationView 탭 색깔 xml 파일 )
전체 Activity의 kotlin 파일
import androidx.fragment.app.Fragment
// androidx에 있는 Fragment를 import 해와야 함
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// 아래 3번에서 구현한 Fragment들을 가져옴
val homeFragment = HomeFragment()
val chatListFragment = ChatListFragment()
val myPageFragment = MyPageFragment()
val bottomNavigationView = findViewById<BottomNavigationView>(R.id.bottomNavigationView)
// fragment 초기값
replaceFragment(homeFragment)
// setOnNavigationItemSelectedListener는 네비게이션바의 탭들이 선택되었을 때 호출되어 선택된 탭의 id가 내려온다.
bottomNavigationView.setOnNavigationItemSelectedListener {
when (it.itemId) {
R.id.home -> replaceFragment(homeFragment)
R.id.chatList -> replaceFragment(chatListFragment)
R.id.myPage -> replaceFragment(myPageFragment)
}
return@setOnNavigationItemSelectedListener true
}
}
// FrameLayout에 선택된 Fragment를 attach하는 메소드
private fun replaceFragment(fragment: Fragment) {
supportFragmentManager.beginTransaction()
.apply {
replace(R.id.fragmentContainer, fragment)
commit()
}
}
}
이 부분에서 Fragment를 가져옴
val homeFragment = HomeFragment()
val chatListFragment = ChatListFragment()
val myPageFragment = MyPageFragment()
이 부분에서 사용자가 탭을 선택할 때마다 그 탭에 맞는 Fragment가 화면에 나타나도록 리스너 설정을 한다.
......
val bottomNavigationView = findViewById<BottomNavigationView>(R.id.bottomNavigationView)
// fragment 초기값
replaceFragment(homeFragment)
bottomNavigationView.setOnNavigationItemSelectedListener {
when (it.itemId) {
R.id.home -> replaceFragment(homeFragment)
R.id.chatList -> replaceFragment(chatListFragment)
R.id.myPage -> replaceFragment(myPageFragment)
}
return@setOnNavigationItemSelectedListener true
}
}
// FrameLayout에 선택된 Fragment를 attach하는 메소드
private fun replaceFragment(fragment: Fragment) {
supportFragmentManager.beginTransaction()
.apply {
replace(R.id.fragmentContainer, fragment)
commit()
}
}
}
이 부분에서 선택된 Fragment에 따라 FrameLayout을 해당 Fragment로 덮어씌우는 메소드를 생성한다.
private fun replaceFragment(fragment: Fragment) {
supportFragmentManager.beginTransaction()
.apply {
replace(R.id.fragmentContainer, fragment)
commit()
}
}
액티비티에는 SupportFragmentManager라는 것이 있는데
--> Activity에 attach되어있는 Fragment를 관리해주는 기능을 함
( 즉, SupportFragmentManager를 가져와서 가져온 Fragment를 관리해달라고 요청하는 것임 )
여기서는 SupportFragmentManager의 Transaction을 사용하여 FrameLayout을 Fragment로 대체하는 작업을 진행함
여기서 Transaction 이란?
--> 해당 작업이 시작한다고 알려주는 기능
--> Transaction에서 시작하여 commit으로 끝날 때까지 해당 작업만 하라는 뉘양스의 api임
이 부분에서 BottomNavigationView에 리스너 설정
val bottomNavigationView = findViewById<BottomNavigationView>(R.id.bottomNavigationView)
bottomNavigationView.setOnNavigationItemSelectedListener {
when (it.itemId) {
R.id.home -> replaceFragment(homeFragment)
R.id.chatList -> replaceFragment(chatListFragment)
R.id.myPage -> replaceFragment(myPageFragment)
}
return@setOnNavigationItemSelectedListener true
}
네비게이션뷰에 setOnNavigationItemSelectedListener()를 설정해서 사용자가 네비게이션 탭을 선택할 때마다
선택에 대한 작업을 구현해준다.
코드를 보면 when문을 사용하여 선택된 item의 itemId를 통해 선택된 네비게이션 탭을 구분하고 있는데,
이 itemId는 위의 1번에서 네비게이션뷰의 탭 메뉴를 만들 때 만들었던 item태그의 id 속성값을 나타낸다.
이렇게 밖에 코드를 따로 빼둔 이유는 탭의 초기값 때문이다.( 처음 켜져서 아직 탭이 선택되지 않았을 때 초기값 )
// fragment 초기값
replaceFragment(homeFragment)
Fragment에 대한 자세한 부분은 아래에 정리해 놓았다.
여기서는 BottomNavigationView에서의 Fragment 활용에 좀 더 집중하여 설명한다.
BottomNavigationView에서 Fragment의 원리를 설명하자면
2번에서 FrameLayout의 영역이 위의 빨간줄 쳐놓은 부분인데,
파란색으로 해 놓은 네비게이션뷰의 메뉴들을 클릭하면
FrameLayout이 Fragment로 대체되어 화면에 나타나게 되는 것이다.
따라서
기본적인 틀
......
// Fragment에서는 ContentView를 다음과 같이 파라미터로 넣어서 설정한다.
class HomeFragment : Fragment(R.layout.fragment_home) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
}
}
여기서 주의해야 할 점은 우리가 기존에 사용하던 Activity를 상속받는 class가 아닌
Fragment를 상속받는 class라는 점이다.
--> 이에 따라 Context 수급 문제,
Acitivity와 Fragment간의 생명주기의 차이로 인해 발생할 문제점들 등
여러가지 다른 고려해야할 점들이 나타난다.
Activity와는 다르게 상속하는 Fragment의 파라미터로
레이아웃의 id를 전달하는 것으로 해당 Fragment에 레이아웃을 설정함
Activity와 다르게 Fragment에서는 onCreate()가 아니라 onViewCreated()를 구현하여 시작 메소드로 사용한다.
onCreate()는 리소스까지 초기화하여 실행하지만,
onViewCreated()는 View까지만 초기화하여 실행한다.
--> 따라서 Fragment가 재사용되어 화면에 나타날 때,
리소스가 초기화되지 않아 문제가 발생할 수 있으므로
Fragment는 리소스관리에 좀 더 신경써야 한다.
ViewBinding을 Fragment에 적용하기
......
// Fragment에서는 ContentView를 다음과 같이 파라미터로 넣어서 설정한다.
class HomeFragment : Fragment(R.layout.fragment_home) {
private var binding: FragmentHomeBinding? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val fragmentHomeBinding = FragmentHomeBinding.bind(view)
binding = fragmentHomeBinding
}
}
Activity에서처럼 layoutInflater()를 가져오는 것이 아닌
다음과 같이 onViewCreated()의 view 파라미터를 bind()메소드에 인자로 넣어주면 된다.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val fragmentHomeBinding = FragmentHomeBinding.bind(view)
}
Acitivity가 아닌 Fragment이기 때문에 고려해야할 점들
context 가져오기
--> context가 필요한 부분에서 Acitivity에서는 this로 가져오면 되지만,
Fragment는 context를 따로 가져와야 한다.
context 키워드로 가져오기
( context는 Nullable하기 때문에 다음과 같이 NullSafe 처리 후 사용 )
// abc(context: Context) 라는 메소드가 있어서 파라미터로 context를 넣어줘야한다고 할때
context?.let{
abc(it)
}
requireContext() 사용하기
( NullSafe 하므로 그냥 사용 )
// abc(context: Context) 라는 메소드가 있어서 파라미터로 context를 넣어줘야한다고 할때
abc(requireContext())
--> 보면 알겠지만 일반적으로 requireContext() 사용해서 가져오는 것이 편하다.
Fragment의 재활용성으로 인해 리스트에 데이터가 쌓이는 문제
Fragment의 경우 onCreate가 아닌 onViewCreated를 사용하는데ㅡ,
onViewCreated는 View가 새롭게 그려질때마다 ( 즉, Fragment를 옮겨다닐 때마다 ) 재실행된다.
--> 따라서 이런 문제가 발생하는 것
BottomNavigationView에서 탭 선택을 통해 Fragment를 옮겨 다닐 경우,
View는 초기화되지만 Fragment 자체가 초기화된 것이 아니기 때문에 데이터가 쌓이게 된다.
( 즉, BottomNavigationView에서 탭 선택을 통해 Fragment를 옮겨다니게 될 때,
선택된 Fragment의 레이아웃은 onViewCreated()에 의해 새롭게 그려지지만, 리소스는 그대로 남아서 재사용되기 때문에 코드에 따라 리스트에 데이터가 중복해서 들어가는 문제가 발생한다.
-->따라서 View가 초기화될 때 다음과 같이 데이터도 초기화되도록 만들어줘야 한다.
......
// Fragment에서는 ContentView를 다음과 같이 파라미터로 넣어서 설정한다.
class HomeFragment : Fragment(R.layout.fragment_home) {
private val articleList = mutableListOf<ArticleModel>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
articleList.clear()
......
// 대충 articleList에 데이터를 추가하여 넣는 코드
//( 초기화가 아닌 추가해서 넣는 코드 )
}
}
예를 들어, Fragment가 실행될 때,
해당 리스트에 1,2,3이 추가로 들어간다고 치면
// 대략 이런 느낌으로
articleList.add(1)
articleList.add(2)
articleList.add(3)
아래의 초기화 코드를 넣어주지 않을 경우
articleList.clear()
해당 탭이 주기적으로 3번 선택되어 Fragment가 3번 실행되었을 때,
Fragment의 리소스는 초기화되지 않고 재사용되므로
해당 리스트에 추가적으로 데이터가 쌓이게 되어
리스트의 내용은 [1,2,3,1,2,3,1,2,3]이 된다.
--> 반면에 초기화 시켜줄 경우,
해당 Fragment가 실행될 때마다 리스트는 빈 리스트로 다시 돌아간 후
데이터가 들어가므로 문제 없이 항상
리스트의 내용이 [1,2,3]으로 유지 된다.
Fragment의 재사용성으로 인해 발생할 후 있는 문제 예방
Fragment의 경우 onCreate가 아닌 onViewCreated를 사용하는데ㅡ,
onViewCreated는 View가 새롭게 그려질때마다 ( 즉, Fragment를 옮겨다닐 때마다 ) 재실행된다.
--> 따라서 이런 문제가 발생하는 것
액티비티의 경우 재사용하지 않기 때문에 문제 없지만 Fragment의 경우 재사용되기 때문에
.addChildEventListener()와 같은 이벤트 메소드를 한번 붙여놓고
onViewCreated() 될 때마다 붙이게 된다면 중복해서 이벤트 리스너가 붙여질 가능성이 있다.
따라서 이벤트 리스너를 전역으로 정의해놓고 onViewCreated()가 될 때마다
(즉, Fragment가 생성될 때마다)
붙여주고,
Fragment가 Destroy될 때마다 remove를 해주는 방식을 사용해야 한다.
......
// Fragment에서는 ContentView를 다음과 같이 파라미터로 넣어서 설정한다.
class HomeFragment : Fragment(R.layout.fragment_home) {
private val listener = object : ChildEventListener {
override fun onChildAdded(snapshot: DataSnapshot, previousChildName: String?) {
val articleModel = snapshot.getValue(ArticleModel::class.java)
articleModel ?: return
articleList.add(articleModel)
articleAdapter.submitList(articleList)
}
override fun onChildChanged(snapshot: DataSnapshot, previousChildName: String?) {}
override fun onChildRemoved(snapshot: DataSnapshot) {}
override fun onChildMoved(snapshot: DataSnapshot, previousChildName: String?) {}
override fun onCancelled(error: DatabaseError) {}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
......
// onViewCreated()가 될 때마다(즉, Fragment가 생성될 때마다) 이벤트 리스너를 붙여줌
articleDB.addChildEventListener(listener)
}
......
override fun onResume() {
super.onResume()
// View가( Fragment가 ) 다시 보일 때마다 View를 다시 그리는 것으로 리스트의 값을 최신으로 갱신
articleAdapter.notifyDataSetChanged()
}
override fun onDestroyView() {
super.onDestroyView()
// Fragment가 Destroy될 때마다 이벤트 리스너가 remove를 해주는 방식을 사용해야 한다.
articleDB.removeEventListener(listener)
}
}
--> 위에 BottomNavigationView는 각 탭에 Fragment를 올려서 사용한다.
Fragment는 자체적인 입력 이벤트를 처리할 수 있음
Fragment는 독립적인 수명주기를 가짐
하지만 Fragment 혼자 존재할 수는 없음
--> 항상 Activity 위에서 존재해야 함
재사용성이 있는 View모음으로 CustomView를 만들었던 것처럼,
비슷한 역할을 하는 View모음을 Fragment로 만들어 놓으면
좀 더 편리하게 재사용할 수 있음
--> 이 경우 UI의 모듈성이 올라가기 때문에 좀 더 쉽게 재사용 할 수 있음
Activity가 Lifecycle를 가진 것처럼
Fragment도 Activity와는 다른, 하지만 비슷한 Lifecycle을 가지고 있음
공식문서 : https://developer.android.com/guide/fragments/lifecycle
Fragment의 생명주기를 위와 같이 Fragment자체와 View로 나눠서 보는 이유는
Fragment가 시작메소드로 onViewCreated()를 사용하며,
이에 따라 View의 생명주기와 Fragment 자체의 생명주기를 고려한 코딩을 해줘야하기 때문이다.
Fragment는 BottomNavigationView에서 많이 사용하게 될 텐데,
BottomNavigationView를 다룰 때 도움이 될 Fragment의 생명주기를 말해주자면 다음과 같다.
해당 Fragment의 탭이 선택되면, Fragment가 생성된다.
( 즉, onResume() 메소드가 확정적으로 실행된다.)
BottomNavigationView에서 Fragment를 다룰 경우,
탭 선택으로 인해 해당 Fragment가 재생성되었을때
현재 자원의 데이터를 반영하도록 해주어야 한다.
--> onResume() 메소드에 이런 부분을 구현한다.
예시 ) 이번 앱
override fun onResume() {
super.onResume()
// View가( Fragment가 ) 다시 보일 때마다 View를 다시 그리는 것으로 리스트의 값을 최신으로 갱신
articleAdapter.notifyDataSetChanged()
}
다른 Fragment의 탭이 선택되면, 기존의 Fragment의 View는 제거 된다.
( 즉, onViewDestroy() 메소드가 확정적으로 실행된다. )
여기서 주의할 점은,
Fragment가 아닌 해당 View의 Destroy이기 때문에
자원( 변수, 리스너설정 등등 )은 없어지지 않고
View만 Destroy 된다고 보면된다.
BottomNavigationView에서 Fragment를 다룰 경우,
다른 탭 선택으로 인해 탭을 벗어난 뒤 ( onViewDestroy() 호출 )
다시 해당 탭을 선택할 때 ( onViewCreated() 호출 )
자원이 없어지지 않은 상태에서
onViewCreated()메소드가 한번 더 호출되면서
리스너등이 중복으로 할당되는 문제가 발생할 수 있다.
--> onViewDestroy() 메소드를 활용하여, 자원관리를 해줘야한다.
예시) 이번 앱
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Fragment를 옮겨 다닐 경우, View는 초기화되지만 Fragment 자체가 초기화된 것이 아니기 때문에 데이터가 쌓이게 된다.
// 따라서 View가 초기화될 때 다음과 같이 데이터도 초기화되도록 만들어줘야 한다.
articleList.clear()
......
// onViewCreated()가 될 때마다(즉, Fragment의 View가 생성될 때마다) 이벤트 리스너를 붙여줌
articleDB.addChildEventListener(listener)
}
override fun onDestroyView() {
super.onDestroyView()
// Fragment의 View가 Destroy될 때마다 이벤트 리스너가 remove를 해주는 방식을 사용해야 한다.
articleDB.removeEventListener(listener)
}
처음 특정 탭이 선택되어 Fragment가 화면에 나타날 때
onViewCreated()가 호출되어 리스너가 할당될 경우,
다른 탭이 선택된 후 다시 해당 탭이 선택되는 상황에서
(즉, 탭 선택으로 인해 해당 Fragment가 화면을 벗어닸다가 다시 화면에 나타날 때)
onViewCreated()가 호출되어 이 리스너가 다시 할당될 것이다.
onViewDestroy() 메소드를 활용하여 Fragment가 화면에 벗어난 시점에서 기존의 리스너의 자원을 반환해주는 것으로 자원관리를 하였다.
--> 위의 BottomNavigationView에서 Fragment 사용에 대한 더 자세한 설명들이 있음
Fragment 구성
Fragment_home.xml --> Fragment의 레이아웃
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#00ff00">
</androidx.constraintlayout.widget.ConstraintLayout>
HomeFragment.kt --> Fragment 객체
package com.example.aop_part3_chapter14.home
import androidx.fragment.app.Fragment
import com.example.aop_part3_chapter14.R
// Fragment에서는 ContentView를 다음과 같이 파라미터로 넣어서 설정한다.
class HomeFragment : Fragment(R.layout.fragment_home){
private var binding: FragmentHomeBinding? = null
// onCreate가 아님
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val fragmentHomeBinding = FragmentHomeBinding.bind(view)
binding = fragmentHomeBinding
}
}
Fragment 를 상속받고 있음
Activity와는 다르게 상속하는 Fragment의 파라미터로
레이아웃의 id를 전달하는 것으로 해당 Fragment에 레이아웃을 설정함
Activity와는 다르게 시작 메소드가 onCreate()가 아닌 onViewCreated()이다.
Fragment에서도 onCreate()를 사용할 수 있지만,
Fragment의 onCreate() 작업은 해당 Fragment를 사용하는 Acitivity의 onCreate()의 작업이 완료되기 전에 완료되므로 (위계상),
Fragment에서 onCreate()를 사용하여 변수할당등의 작업을 진행할 경우 crash가 발생할 수 있다.
따라서 View가 그려지는 시점에 호출되는 onViewCreated()에 Fragment의 변수할당 등의 자원을 다루는 작업들을 진행하는 것을 추천한다.
( View가 만들어지는 시점에 호출되는 메소드를 통해 자원을 할당하는 것으므로 자원관리 또한 해줘야 한다. )
-> 이에 대한 것은 위의 생명주기에서 다뤘음
Activity에 Fragment 삽입
activity_main.xml --> Fragment를 넣기 위한 View를 생성
<?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"
tools:context=".MainActivity">
<!-- Fragment가 붙기 위한 레이아웃-->
<FrameLayout
android:id="@+id/fragmentContainer"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@+id/bottomNavigationView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottomNavigationView"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:itemIconTint="@drawable/selector_menu_color"
app:itemRippleColor="@null"
app:itemTextColor="@color/black"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:menu="@menu/bottom_navigation_menu" />
</androidx.constraintlayout.widget.ConstraintLayout>
일반적으로는 위와 같이 FrameLayout을 넣어서
Fragment를 위한 View공간을 확보함
( Fragment를 Activity에 넣을 때, 위의 FrameLayout을 대신하여 들어감 )
보통 Fragment는 위와 같이
BottomNavigationView의 탭별 화면구성에 많이 사용된다.
( BottomNavigationView의 탭을 클릭하면
그 탭에 맞는 Fragment로 FrameLayout을 대체하는 식 )
MainActivity.kt --> Fragment를 담는 코드
( 위의 FrameLayout을 Fragment로 대체하는 코드 )
...
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val homeFragment = HomeFragment()
supportFragmentManager.beginTransaction()
.apply {
replace(R.id.fragmentContainer, homeFragment)
commit()
}
}
}
액티비티에는 SupportFragmentManager라는 것이 있음
--> Activity에 attach되어있는 Fragment를 관리해주는 기능
이런 SupportFragmentManager를 가져와서 Fragment를 관리해달라고 요청
여기서 Transaction은 작업이 시작한다고 알려주는 기능
Transaction은 해당 작업을 시작하여
commit으로 끝날 때까지 해당 작업만 하라는 뉘양스의 api임
위에서 Transaction의 작업은 다음과 같다.
beginTransaction()으로 SupportFragmentManager에서
Transaction을 열었음 ( Transaction 작업 시작 )
replace() 메소드를 통해 FrameLayout을 사용자가 선택한 fragment로 대체함
SupportFragmentManager의 멤버함수인 replace()는
첫번째 파라미터로 대체 될 View 혹은 Layout을 받고
두번째 파라미터로 대체하여 들어갈 Fragment을 받는다.
--> 즉, 여기서는 FrameLayout을 fragment로 대체한 것이다.
commit()을 통해 해당 Transaction을 내용을 확정 및 종료함
package com.example.aop_part3_chapter14
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.fragment.app.Fragment
import com.example.aop_part3_chapter14.chatlist.ChatListFragment
import com.example.aop_part3_chapter14.home.HomeFragment
import com.example.aop_part3_chapter14.mypage.MyPageFragment
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.snackbar.Snackbar
// androidx에 있는 Fragment를 import 해와야 함
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val homeFragment = HomeFragment()
val chatListFragment = ChatListFragment()
val myPageFragment = MyPageFragment()
val bottomNavigationView = findViewById<BottomNavigationView>(R.id.bottomNavigationView)
// fragment 초기값
replaceFragment(homeFragment)
// setOnNavigationItemSelectedListener는 네비게이션바의 탭들이 선택되었을 때 호출되어 선택된 탭의 id가 내려온다.
bottomNavigationView.setOnNavigationItemSelectedListener {
when (it.itemId) {
R.id.home -> replaceFragment(homeFragment)
R.id.chatList -> replaceFragment(chatListFragment)
R.id.myPage -> replaceFragment(myPageFragment)
}
return@setOnNavigationItemSelectedListener true
}
}
// FrameLayout에 선택된 Fragment를 attach하는 메소드
private fun replaceFragment(fragment: Fragment) {
supportFragmentManager.beginTransaction()
.apply {
replace(R.id.fragmentContainer, fragment)
commit()
}
}
}
<?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"
tools:context=".MainActivity">
<!-- Fragment가 붙기 위한 레이아웃-->
<FrameLayout
android:id="@+id/fragmentContainer"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@+id/bottomNavigationView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottomNavigationView"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:itemIconTint="@drawable/selector_menu_color"
app:itemRippleColor="@null"
app:itemTextColor="@color/black"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:menu="@menu/bottom_navigation_menu" />
</androidx.constraintlayout.widget.ConstraintLayout>
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/home"
android:icon="@drawable/ic_baseline_home_24"
android:title="@string/home" />
<item
android:id="@+id/chatList"
android:icon="@drawable/ic_baseline_chat_24"
android:title="@string/chatting" />
<item
android:id="@+id/myPage"
android:icon="@drawable/ic_baseline_person_24"
android:title="@string/myInfo" />
</menu>
<!-- 네비게이션 탭에 들어갈 탭들-->
<!-- item태그로 관리하며,-->
<!-- item태그의 id는 식별자-->
<!-- item태그의 icon은 나타날 아이콘-->
<!-- item태그의 title은 나타날 글자를 의미한다.-->
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/black" android:state_checked="true"/>
<item android:color="@color/gray_cc" android:state_checked="false"/>
</selector>
<!--네비게이션바의 탭메뉴가 선택되었을 때, 선택되지 않았을 때의 색을 설정해주기 위한 selector-->
package com.example.aop_part3_chapter14.home
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.aop_part3_chapter14.DBKey.Companion.CHILD_CHAT
import com.example.aop_part3_chapter14.DBKey.Companion.DB_ARTICLES
import com.example.aop_part3_chapter14.DBKey.Companion.DB_USERS
import com.example.aop_part3_chapter14.R
import com.example.aop_part3_chapter14.chatlist.ChatListItem
import com.example.aop_part3_chapter14.databinding.FragmentHomeBinding
import com.google.android.material.snackbar.Snackbar
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.ktx.auth
import com.google.firebase.database.*
import com.google.firebase.database.ktx.database
import com.google.firebase.ktx.Firebase
// Fragment에서는 ContentView를 다음과 같이 파라미터로 넣어서 설정한다.
class HomeFragment : Fragment(R.layout.fragment_home) {
private var binding: FragmentHomeBinding? = null
private lateinit var articleAdapter: ArticleAdapter
private lateinit var articleDB: DatabaseReference
private lateinit var userDB: DatabaseReference
private val articleList = mutableListOf<ArticleModel>()
// 액티비티의 경우 재사용하지 않기 때문에 문제 없지만
// Fragment의 경우 재사용되기 때문에
// .addChildEventListener()와 같은 이벤트 메소드를 한번 붙여놓고 onViewCreated() 될 때마다 붙이게 된다면
// 중복해서 이벤트 리스너가 붙여질 가능성이 있다.
// 따라서 이벤트 리스너를 전역으로 정의해놓고 onViewCreated()가 될 때마다(즉, Fragment가 생성될 때마다) 붙여주고, Fragment가 Destroy될 때마다 remove를 해주는 방식을 사용해야 한다.
private val listener = object : ChildEventListener {
override fun onChildAdded(snapshot: DataSnapshot, previousChildName: String?) {
// Data Model을 만들어 DB에 한번에 올리고 가져옴
val articleModel = snapshot.getValue(ArticleModel::class.java)
articleModel ?: return
articleList.add(articleModel)
articleAdapter.submitList(articleList)
}
override fun onChildChanged(snapshot: DataSnapshot, previousChildName: String?) {}
override fun onChildRemoved(snapshot: DataSnapshot) {}
override fun onChildMoved(snapshot: DataSnapshot, previousChildName: String?) {}
override fun onCancelled(error: DatabaseError) {}
}
private val auth: FirebaseAuth by lazy {
Firebase.auth
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val fragmentHomeBinding = FragmentHomeBinding.bind(view)
binding = fragmentHomeBinding
// Fragment를 옮겨 다닐 경우, View는 초기화되지만 HomeFragment 자체가 초기화된 것이 아니기 때문에 데이터가 쌓이게 된다.
// 따라서 View가 초기화될 때 다음과 같이 데이터도 초기화되도록 만들어줘야 한다.
articleList.clear()
articleDB = Firebase.database.reference.child(DB_ARTICLES)
userDB = Firebase.database.reference.child(DB_USERS)
articleAdapter = ArticleAdapter(onItemClicked = { articleModel->
if (auth.currentUser != null){
// todo 로그인을 한 상태
if(auth.currentUser!!.uid != articleModel.sellerId){
val chatRoom = ChatListItem(
buyerId = auth.currentUser!!.uid,
sellerId = articleModel.sellerId,
itemTitle = articleModel.title,
key = System.currentTimeMillis()
)
userDB.child(auth.currentUser!!.uid)
.child(CHILD_CHAT)
.push()
.setValue(chatRoom)
userDB.child(articleModel.sellerId)
.child(CHILD_CHAT)
.push()
.setValue(chatRoom)
Snackbar.make(view,"채팅방이 생성되었습니다. 채팅 탭에서 확인해주세요.", Snackbar.LENGTH_LONG).show()
} else {
// todo 내가 올린 아이템일 때
Snackbar.make(view,"내가 올린 아이템 입니다.", Snackbar.LENGTH_LONG).show()
}
} else {
// todo 로그인을 안한 상태
Snackbar.make(view,"로그인 후 사용해주세요", Snackbar.LENGTH_LONG).show()
}
} )
// Fragment는 context를 가지고 있지 않으므로 context키워드를 통해 가져옴
fragmentHomeBinding.articleRecyclerView.layoutManager = LinearLayoutManager(context)
fragmentHomeBinding.articleRecyclerView.adapter = articleAdapter
fragmentHomeBinding.addFloatingButton.setOnClickListener{
if(auth.currentUser != null){
val intent = Intent(requireContext(), AddArticleActivity::class.java)
startActivity(intent)
}else{
// Toast와 동일한 기능, Toast와 인터페이스가 약간 다름
Snackbar.make(view,"로그인 후 사용해주세요", Snackbar.LENGTH_LONG).show()
}
}
// onViewCreated()가 될 때마다(즉, Fragment가 생성될 때마다) 이벤트 리스너를 붙여줌
articleDB.addChildEventListener(listener)
}
override fun onResume() {
super.onResume()
// View가( Fragment가 ) 다시 보일 때마다 View를 다시 그리는 것으로 리스트의 값을 최신으로 갱신
articleAdapter.notifyDataSetChanged()
}
override fun onDestroyView() {
super.onDestroyView()
// Fragment의 View가 Destroy될 때마다 이벤트 리스너가 remove를 해주는 방식을 사용해야 한다.
articleDB.removeEventListener(listener)
}
}
<?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">
<LinearLayout
android:id="@+id/toolbarLayout"
android:layout_width="0dp"
android:layout_height="?actionBarSize"
android:gravity="center_vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:text="중고거래"
android:textColor="@color/black"
android:textSize="20sp"
android:textStyle="bold" />
</LinearLayout>
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:background="@color/gray_cc"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbarLayout" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/articleRecyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbarLayout" />
<!-- 플로팅 버튼-->
<!-- material 계열의 컴포넌트는 배경색을 background가 아니라 backgroundTint에다가 줘야 적용된다.-->
<!-- 내부에 들어갈 Vector Drawable의 색은 app:tint의 값을 바꿔주면 된다.-->
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/addFloatingButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:backgroundTint="@color/orange"
android:src="@drawable/ic_baseline_add_24"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:tint="@color/white" />
</androidx.constraintlayout.widget.ConstraintLayout>
package com.example.aop_part3_chapter14.home
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.example.aop_part3_chapter14.databinding.ItemArticleBinding
import java.text.SimpleDateFormat
import java.util.*
class ArticleAdapter(val onItemClicked: (ArticleModel) -> Unit) : ListAdapter<ArticleModel, ArticleAdapter.ViewHolder>(diffUtil) {
// ViewBinding을 통해 레이아웃에서 가져옴
inner class ViewHolder(private val binding: ItemArticleBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(articleModel: ArticleModel) {
val format = SimpleDateFormat("MM월 dd일")
val date = Date(articleModel.createdAt)
binding.titleTextView.text = articleModel.title
binding.dateTextView.text = format.format(date).toString()
binding.priceTextView.text = articleModel.price
if (articleModel.imageUrl.isNotEmpty()) {
Glide.with(binding.thumbnailImageView)
.load(articleModel.imageUrl)
.into(binding.thumbnailImageView)
}
binding.root.setOnClickListener{
onItemClicked(articleModel)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(
ItemArticleBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(currentList[position])
}
companion object {
val diffUtil = object : DiffUtil.ItemCallback<ArticleModel>() {
// 현재 노출되고 있는 아이템과 새로운 아이템이 같은지 확인 ㅡ, 새로운 아이템이 들어오면 호출됨
// 일반적으로 키값을 통해 구분하게 됨
override fun areItemsTheSame(oldItem: ArticleModel, newItem: ArticleModel): Boolean {
return oldItem.createdAt == newItem.createdAt
}
// 현재 아이템과 새로운 아이탬의 = 여부를 확인
override fun areContentsTheSame(oldItem: ArticleModel, newItem: ArticleModel): Boolean {
return oldItem == newItem
}
}
}
}
<?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="wrap_content"
android:paddingHorizontal="16dp"
android:paddingTop="16dp">
<ImageView
android:id="@+id/thumbnailImageView"
android:layout_width="110dp"
android:layout_height="110dp"
android:layout_marginBottom="16dp"
android:scaleType="center"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/titleTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:maxLines="2"
android:textColor="@color/black"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/thumbnailImageView"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/dateTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/titleTextView"
app:layout_constraintTop_toBottomOf="@+id/titleTextView" />
<TextView
android:id="@+id/priceTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textColor="@color/black"
android:textSize="15sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/titleTextView"
app:layout_constraintTop_toBottomOf="@+id/dateTextView" />
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:background="@color/gray_ec"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
package com.example.aop_part3_chapter14.home
import android.app.Activity
import android.app.AlertDialog
import android.content.DialogInterface
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.widget.*
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.content.PackageManagerCompat
import androidx.core.view.isVisible
import com.example.aop_part3_chapter14.DBKey.Companion.DB_ARTICLES
import com.example.aop_part3_chapter14.R
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.ktx.auth
import com.google.firebase.database.DatabaseReference
import com.google.firebase.database.ktx.database
import com.google.firebase.ktx.Firebase
import com.google.firebase.storage.FirebaseStorage
import com.google.firebase.storage.ktx.storage
class AddArticleActivity : AppCompatActivity() {
private var selectedUri: Uri? = null
private val auth: FirebaseAuth by lazy {
Firebase.auth
}
private val storage: FirebaseStorage by lazy {
Firebase.storage
}
private val articleDB: DatabaseReference by lazy {
Firebase.database.reference.child(DB_ARTICLES)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_add_article)
findViewById<Button>(R.id.imageAddButton).setOnClickListener {
when {
ContextCompat.checkSelfPermission(
this,
android.Manifest.permission.READ_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED -> {
startContentProvider()
}
shouldShowRequestPermissionRationale(android.Manifest.permission.READ_EXTERNAL_STORAGE) -> {
showPermissionContextPopup()
}
else -> {
requestPermissions(
arrayOf(android.Manifest.permission.READ_EXTERNAL_STORAGE),
1010
)
}
}
}
// push().setValue() 를 통해 해당 DB위치에 ArticleModel을 통으로 올려서 저장
// 이렇게 Data Model을 통으로 push()하여 넣으면, Realtime Database 상에는
// 헤당 DB 위치에 임의의 식별용 Key를 만들고 그 안에 해당 DataModel의 데이터를 넣게 된다.
// ( 즉, 데이터를 새롭게 넣을 때마다 해당 위치에 임의의 Key가 만들어지고, 그 Key의 Value로서 Data Model이 통으로 들어가게 된다. )
findViewById<Button>(R.id.submitButton).setOnClickListener {
val title = findViewById<EditText>(R.id.titleEditText).text.toString().orEmpty()
val price = findViewById<EditText>(R.id.priceEditText).text.toString().orEmpty()
val sellerId = auth.currentUser?.uid.orEmpty()
// 해당 작업은 test 결과 경우에 따라 2초 정도 요소되는 등 상당히 긴 시간이 소요되었다..
// 따라서 사용자가 진행이 정상적으로 되었고 있다고 인식할 수 있도록 하기 위해 ProgressBar를 병행해서 사용해줘야 한다.
// 만약 ProgressBar 도중에 사용자의 Touch로 인한 TouchEvent를 막고 싶다면,
// View를 하나 더 만들어서 그 최상단의 View가 ProgressBar가 돌고 있는 중에 나머지의 Touch를 막는 코드를 작성하면 된다.
showProgress()
// todo 중간에 이미지가 있으면 업로드 과정을 추가
if (selectedUri != null) {
// 매우 희박한 확률로 다른 쓰래드에 의해 해당 변수가 null처리 될 수도 있으므로 예외처리
val PhotoUri = selectedUri ?: return@setOnClickListener
uploadPhoto(
PhotoUri,
successHandler = { uri ->
uploadArticle(sellerId, title, price, uri)
},
errorHandler = {
Toast.makeText(this, "사진 업로드에 실패했습니다.", Toast.LENGTH_SHORT).show()
hideProgress()
// ProgressBar 감추기
}
)
} else {
uploadArticle(sellerId, title, price, "")
}
}
}
// firebase Storage에 데이터 넣기 + 데이터 가져오기 를 구현한 메소드 생성
private fun uploadPhoto(uri: Uri, successHandler: (String) -> Unit, errorHandler: () -> Unit) {
// 이미지 파일의 이름에 대한 형식설정( 이름이 겹치면 안되므로 현재 시간을 이름으로 함 )
val fileName = "${System.currentTimeMillis()}.png"
// 데이터 넣는 부분 --> DB의 최상위 항목에 접근하는 부분까지는 RealtimeDB와 동일함
// 하지만, DB를 탐색하는 부분은 파일탐색기와 비슷한 방식을 사용 -> 예를들어, child("article/photo")이라면 article폴더 안의 photo폴더 안으로 이동
// 이후 child(파일 이름)를 통해 해당 영역에 파일을 만들어 줄 수 있음 ( 적합한 확장자를 포함시킬 것 )
// addOnCompliteListener를 통해 데이터가 올바르게 들어갔는지 확인 및 그에 따른 처리를 해줄 수 있음
storage.reference.child("article/photo").child(fileName)
.putFile(uri)
.addOnCompleteListener {
if (it.isSuccessful) {
// 데이터가 잘 들어갔다면
// 데이터를 가져오는 부분
// DB에서 원하는 이미지 항목에 접근, downloadUrl에 해당 항목의 Value가 들어있으므로 그것을 가져옴
// 이후 addOnSuccessListener와 addOnFailureListener를 통해 데이터를 성공적으로 가져온 경우와 그렇지 못한 경우에 대한 처리
storage.reference.child("article/photo").child(fileName)
.downloadUrl
.addOnSuccessListener { uri ->
successHandler(uri.toString())
}
.addOnFailureListener {
errorHandler()
}
} else {
errorHandler()
}
}
}
// 위의 코드에서 이 메소드가 사용된 부분을 보면 if로 image를 다루는 넣는 영역은 비동기이고 else로 빠진 영역은 동기이므로,
// 메소드를 사용하여 각각의 영역에서 데이터를 넣는 식으로 코딩해주어야 한다.
private fun uploadArticle(sellerId: String, title: String, price: String, imageUrl: String) {
val model = ArticleModel(sellerId, title, System.currentTimeMillis(), "$price 원", imageUrl)
articleDB.push().setValue(model)
hideProgress()
// ProgressBar 감추기
finish()
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
1010 ->
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
startContentProvider()
} else {
Toast.makeText(this, "권한을 거부하셨습니다", Toast.LENGTH_SHORT).show()
}
}
}
private fun startContentProvider() {
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.type = "image/*"
startActivityForResult(intent, 2020)
}
private fun showProgress(){
findViewById<ProgressBar>(R.id.progressBar).isVisible = true
}
private fun hideProgress(){
findViewById<ProgressBar>(R.id.progressBar).isVisible = false
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode != Activity.RESULT_OK) {
return
}
when (requestCode) {
2020 -> {
val uri = data?.data
if (uri != null) {
findViewById<ImageView>(R.id.photoImageView).setImageURI(uri)
selectedUri = uri
} else {
Toast.makeText(this, "사진을 가져오지 못했습니다", Toast.LENGTH_SHORT).show()
}
}
else -> {
Toast.makeText(this, "사진을 가져오지 못했습니다", Toast.LENGTH_SHORT).show()
}
}
}
private fun showPermissionContextPopup() {
AlertDialog.Builder(this)
.setTitle("권한이 필요합니다.")
.setMessage("사진을 가져오기 위해 필요합니다")
.setPositiveButton("동의") { _, _ ->
requestPermissions(
arrayOf(android.Manifest.permission.READ_EXTERNAL_STORAGE),
1010
)
}
.create()
.show()
}
}
<?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"
xmlns:tool="http://schemas.android.com/tools">
<LinearLayout
android:id="@+id/toolbarLayout"
android:layout_width="0dp"
android:layout_height="?actionBarSize"
android:gravity="center_vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:text="아이템 등록"
android:textColor="@color/black"
android:textSize="20sp"
android:textStyle="bold" />
</LinearLayout>
<View
android:id="@+id/toolbarUnderLineView"
android:layout_width="0dp"
android:layout_height="1dp"
android:background="@color/gray_cc"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbarLayout" />
<EditText
android:id="@+id/titleEditText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="16dp"
android:hint="글 제목"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbarLayout" />
<EditText
android:id="@+id/priceEditText"
android:layout_width="0dp"
android:inputType="numberDecimal"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:hint="가격"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/titleEditText" />
<Button
android:id="@+id/imageAddButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:backgroundTint="@color/orange"
android:text="이미지 등록하기"
app:layout_constraintEnd_toEndOf="@id/photoImageView"
app:layout_constraintStart_toStartOf="@+id/photoImageView"
app:layout_constraintTop_toBottomOf="@+id/photoImageView" />
<ImageView
android:id="@+id/photoImageView"
android:layout_width="250dp"
android:layout_height="250dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- Button은 theme에 의해 배경색이 고정된 컴포넌트이므로 배경색을 바꾸려면 background가 아니라 backgroundTint를 바꿔줘야 적용됨-->
<Button
android:id="@+id/submitButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="16dp"
android:backgroundTint="@color/orange"
android:text="등록하기"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:visibility="gone"
tool:visibility="visible"/>
</androidx.constraintlayout.widget.ConstraintLayout>
package com.example.aop_part3_chapter14.home
// Firebase RealTime Database에서 그대로 넣고 뺴기 위해 정의한 Data Model
// 이후ㅡ, RealTime Database에서는 해당 객체의 형식으로 통채로 넣고, 해당 객체 형식의 데이터를 통채로 받아올 것이다.
data class ArticleModel(
val sellerId: String,
val title: String,
val createdAt: Long,
val price: String,
val imageUrl: String
){
constructor(): this("","",0,"","")
}
// Firebase RealTime Database에서 Model Class를 통해 데이터를 주고 받고 싶을 떄는
// 반드시 위와 같이 빈 생성자를 정의해줘야 한다. -> ( 아마 null 예외처리 때문인 듯 함 )
package com.example.aop_part3_chapter14.mypage
import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.Toast
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import com.example.aop_part3_chapter14.R
import com.example.aop_part3_chapter14.databinding.FragmentChatlistBinding
import com.example.aop_part3_chapter14.databinding.FragmentMypageBinding
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.ktx.auth
import com.google.firebase.ktx.Firebase
class MyPageFragment : Fragment(R.layout.fragment_mypage) {
private var binding: FragmentMypageBinding? = null
private val auth: FirebaseAuth by lazy {
Firebase.auth
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val fragmentMypageBinding = FragmentMypageBinding.bind(view)
binding = fragmentMypageBinding
fragmentMypageBinding.signInOutButton.setOnClickListener {
binding?.let { binding ->
val email = binding.emailEditText.text.toString()
val password = binding.passwordEditText.text.toString()
if (auth.currentUser == null) {
// todo 로그인
auth.signInWithEmailAndPassword(email, password)
.addOnCompleteListener(requireActivity()) {
if (it.isSuccessful) {
successSignIn()
} else {
Toast.makeText(
context,
"로그인에 실패했습니다. 이메일 또는 비밀번호를 확인해주세요",
Toast.LENGTH_SHORT
).show()
}
}
// addOnCompleteListener() 는 파라미터로 Activity를 받는데, 일반적인 액티비티 영역에서는 this로 사용하지만,
// 이번에는 Fragment영역이므로 activity로 전달해줘야 함
// activity 변수는 nullable이기 때문에 이를 해결하는 방법으로 requireActivity()를 사용하면 되지만,
// requireActivity는 nullsafe이므로 만에 하나 null이 들어오면 앱이 죽어버림
// 따라서 절대 null이 들어가지 않을 부분에서만 requireActivity를 사용해줘야 함
// 가급적이면 그냥 activity?.let{}으로 코드들을 감싸서 사용할 것을 추천
} else {
// todo 로그 아웃
auth.signOut()
binding.emailEditText.text.clear()
binding.emailEditText.isEnabled = true
binding.passwordEditText.text.clear()
binding.passwordEditText.isEnabled = true
binding.signInOutButton.text = "로그인"
binding.signInOutButton.isEnabled = false
binding.signUpButton.isEnabled = false
}
}
}
fragmentMypageBinding.signUpButton.setOnClickListener {
binding?.let { binding ->
val email = binding.emailEditText.text.toString()
val password = binding.passwordEditText.text.toString()
auth.createUserWithEmailAndPassword(email,password)
.addOnCompleteListener(requireActivity()) {
if(it.isSuccessful){
Toast.makeText(context,"회원가입에 성공했습니다. 로그인 버튼을 눌러주세요", Toast.LENGTH_SHORT).show()
}else{
Toast.makeText(context,"회원가입에 실패했습니다. 이미 가입한 이메일일 수 있습니다.", Toast.LENGTH_SHORT).show()
}
}
}
}
fragmentMypageBinding.emailEditText.addTextChangedListener {
binding?.let { binding ->
val enable =
binding.emailEditText.text.isNotEmpty() && binding.passwordEditText.text.isNotEmpty()
binding.signInOutButton.isEnabled = enable
binding.signUpButton.isEnabled = enable
}
}
fragmentMypageBinding.passwordEditText.addTextChangedListener {
binding?.let { binding ->
val enable =
binding.passwordEditText.text.isNotEmpty() && binding.passwordEditText.text.isNotEmpty()
binding.signInOutButton.isEnabled = enable
binding.signUpButton.isEnabled = enable
}
}
}
// 앱에서 잠깐 벗어났다가 들어오는 시점에서 로그인이 풀렸을 가능성이 있으므로 반드시 시작시에 로그인 여부를 확인하여
// 예외처리 해줘야 한다.
override fun onStart() {
super.onStart()
// Start 시점에 로그인이 안되어 있거나 혹은 로그인이 풀린 경우
if (auth.currentUser == null) {
binding?.let { binding ->
binding.emailEditText.text.clear()
binding.emailEditText.isEnabled = true
binding.passwordEditText.text.clear()
binding.passwordEditText.isEnabled = true
binding.signInOutButton.text = "로그인"
binding.signInOutButton.isEnabled = false
binding.signUpButton.isEnabled = false
}
} else {
binding?.let { binding ->
binding.emailEditText.setText(auth.currentUser!!.email)
binding.passwordEditText.setText("********")
binding.emailEditText.isEnabled = false
binding.passwordEditText.isEnabled = false
binding.signInOutButton.text = "로그 아웃"
binding.signInOutButton.isEnabled = true
binding.signUpButton.isEnabled = false
}
}
}
private fun successSignIn() {
if (auth.currentUser == null) {
Toast.makeText(context, "로그인에 실패했습니다. 다시 시도해주세요", Toast.LENGTH_SHORT).show()
return
}
binding?.emailEditText?.isEnabled = false
binding?.passwordEditText?.isEnabled = false
binding?.signUpButton?.isEnabled = false
binding?.signInOutButton?.text = "로그아웃"
}
}
<?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:padding="24dp">
<EditText
android:id="@+id/emailEditText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="50dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<EditText
android:id="@+id/passwordEditText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:inputType="textPassword"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/emailEditText" />
<Button
android:id="@+id/signUpButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:backgroundTint="@color/orange"
android:text="회원가입"
android:enabled="false"
android:layout_marginEnd="10dp"
app:layout_constraintEnd_toStartOf="@id/signInOutButton"
app:layout_constraintTop_toBottomOf="@+id/passwordEditText" />
<Button
android:id="@+id/signInOutButton"
android:layout_width="wrap_content"
android:backgroundTint="@color/orange"
android:text="로그인"
android:enabled="false"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="@+id/passwordEditText"
app:layout_constraintTop_toBottomOf="@+id/passwordEditText" />
</androidx.constraintlayout.widget.ConstraintLayout>
package com.example.aop_part3_chapter14.chatlist
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.aop_part3_chapter14.DBKey.Companion.CHILD_CHAT
import com.example.aop_part3_chapter14.DBKey.Companion.DB_USERS
import com.example.aop_part3_chapter14.R
import com.example.aop_part3_chapter14.chatdetail.ChatRoomActivity
import com.example.aop_part3_chapter14.databinding.FragmentChatlistBinding
import com.example.aop_part3_chapter14.home.ArticleAdapter
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.ktx.auth
import com.google.firebase.database.DataSnapshot
import com.google.firebase.database.DatabaseError
import com.google.firebase.database.DatabaseReference
import com.google.firebase.database.ValueEventListener
import com.google.firebase.database.ktx.database
import com.google.firebase.ktx.Firebase
class ChatListFragment:Fragment(R.layout.fragment_chatlist) {
private var binding: FragmentChatlistBinding? = null
private lateinit var chatListAdapter: ChatListAdapter
private val chatRoomList = mutableListOf<ChatListItem>()
private lateinit var chatDB: DatabaseReference
private val auth: FirebaseAuth by lazy {
Firebase.auth
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val fragmentChatlistBinding = FragmentChatlistBinding.bind(view)
binding = fragmentChatlistBinding
chatListAdapter = ChatListAdapter(onItemClicked = { ChatRoom ->
// todo 채팅방으로 이동하는 코드
val intent = Intent(requireContext(),ChatRoomActivity::class.java)
intent.putExtra("chatKey", ChatRoom.key)
startActivity(intent)
})
chatRoomList.clear()
fragmentChatlistBinding.chatListRecyclerView.adapter = chatListAdapter
fragmentChatlistBinding.chatListRecyclerView.layoutManager = LinearLayoutManager(context)
if (auth.currentUser == null){
return
}
chatDB = Firebase.database.reference.child(DB_USERS).child(auth.currentUser!!.uid).child(CHILD_CHAT)
// todo Data model 통채로 DB에 넣었던 것을 통채로 가져오는 부분 ( DB에 넣을 때 Data model 통채로 넣었기에 통채로 가져오는 것이 가능한 것임 )
// todo SingleValueEvent로 Data model을 불러올 경우에는 해당 리스너가 SingleValue로 데이터를 가져오므로 불러올 영역에 존재하는 Data model들을 담은 하나의 List로 들어오게 된다.
// todo 따라서 forEach로 들어온 List에서 Data model을 하나씩 분리하여 가져와야 한다.
chatDB.addListenerForSingleValueEvent(object : ValueEventListener{
override fun onDataChange(snapshot: DataSnapshot) {
// todo 서버에서 데이터를 가져오는 것에 성공하면 호출
// snapshot.children에 Data model들을 담은 하나의 리스트가 내려옴
// 이 리스트에서 Data model들을 하나씩 분리하는 작업이 필요 ( forEach )
snapshot.children.forEach{
val model = it.getValue(ChatListItem::class.java)
model ?: return
chatRoomList.add(model)
}
chatListAdapter.submitList(chatRoomList)
chatListAdapter.notifyDataSetChanged()
}
override fun onCancelled(error: DatabaseError) {
// TODO 서버에서 데이터를 가져오는 것에 실패했을 경우 호출
}
})
}
override fun onResume() {
super.onResume()
chatListAdapter.notifyDataSetChanged()
}
}
<?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">
<LinearLayout
android:id="@+id/toolbarLayout"
android:layout_width="0dp"
android:layout_height="?actionBarSize"
android:gravity="center_vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:text="채팅방 리스트"
android:textColor="@color/black"
android:textSize="20sp"
android:textStyle="bold" />
</LinearLayout>
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:background="@color/gray_cc"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbarLayout" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/chatListRecyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbarLayout" />
</androidx.constraintlayout.widget.ConstraintLayout>
package com.example.aop_part3_chapter14.chatlist
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.example.aop_part3_chapter14.databinding.ItemArticleBinding
import com.example.aop_part3_chapter14.databinding.ItemChatListBinding
import java.text.SimpleDateFormat
import java.util.*
class ChatListAdapter(val onItemClicked: (ChatListItem) -> Unit) : ListAdapter<ChatListItem, ChatListAdapter.ViewHolder>(diffUtil) {
// ViewBinding을 통해 레이아웃에서 가져옴
inner class ViewHolder(private val binding: ItemChatListBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(chatListItem: ChatListItem) {
binding.root.setOnClickListener{
onItemClicked(chatListItem)
}
binding.chatRoomTitleTextView.text = chatListItem.itemTitle
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(
ItemChatListBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(currentList[position])
}
companion object {
val diffUtil = object : DiffUtil.ItemCallback<ChatListItem>() {
// 현재 노출되고 있는 아이템과 새로운 아이템이 같은지 확인 ㅡ, 새로운 아이템이 들어오면 호출됨
// 일반적으로 키값을 통해 구분하게 됨
override fun areItemsTheSame(oldItem: ChatListItem, newItem: ChatListItem): Boolean {
return oldItem.key == newItem.key
}
// 현재 아이템과 새로운 아이탬의 = 여부를 확인
override fun areContentsTheSame(oldItem: ChatListItem, newItem: ChatListItem): Boolean {
return oldItem == newItem
}
}
}
}
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:padding="10dp"
android:layout_height="match_parent">
<TextView
android:id="@+id/chatRoomTitleTextView"
android:textSize="20sp"
android:textColor="@color/black"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
</LinearLayout>
package com.example.aop_part3_chapter14.chatlist
data class ChatListItem(
val buyerId: String,
val sellerId : String,
val itemTitle: String,
val key: Long
){
constructor(): this("","","",0)
}
package com.example.aop_part3_chapter14.chatdetail
import android.os.Bundle
import android.widget.Button
import android.widget.EditText
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.example.aop_part3_chapter14.DBKey.Companion.DB_CHATS
import com.example.aop_part3_chapter14.R
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.ktx.auth
import com.google.firebase.database.ChildEventListener
import com.google.firebase.database.DataSnapshot
import com.google.firebase.database.DatabaseError
import com.google.firebase.database.DatabaseReference
import com.google.firebase.database.ktx.database
import com.google.firebase.ktx.Firebase
import java.text.SimpleDateFormat
import java.util.*
class ChatRoomActivity : AppCompatActivity() {
private val auth: FirebaseAuth by lazy {
Firebase.auth
}
private val chatList = mutableListOf<ChatItem>()
private val adapter = ChatItemAdapter()
private var chatDB: DatabaseReference? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_chatroom)
val chatkey = intent.getLongExtra("chatKey", -1)
chatDB = Firebase.database.reference.child(DB_CHATS).child(chatkey.toString())
chatDB?.addChildEventListener(object : ChildEventListener {
override fun onChildAdded(snapshot: DataSnapshot, previousChildName: String?) {
val chatItem = snapshot.getValue(ChatItem::class.java)
chatItem ?: return
chatList.add(chatItem)
adapter.submitList(chatList)
adapter.notifyDataSetChanged()
}
override fun onChildChanged(snapshot: DataSnapshot, previousChildName: String?) {}
override fun onChildRemoved(snapshot: DataSnapshot) {}
override fun onChildMoved(snapshot: DataSnapshot, previousChildName: String?) {}
override fun onCancelled(error: DatabaseError) {}
})
findViewById<RecyclerView>(R.id.chatRecyclerView).adapter = adapter
findViewById<RecyclerView>(R.id.chatRecyclerView).layoutManager = LinearLayoutManager(this)
// 현재시간 넣기
val t_date = Date(System.currentTimeMillis())
val nowTime = SimpleDateFormat("yyyy-MM-dd kk:mm", Locale("ko", "KR"))
.format(t_date)
findViewById<Button>(R.id.sendButton).setOnClickListener {
val chatItem = ChatItem(
senderId = auth.currentUser?.uid.orEmpty(),
message = findViewById<EditText>(R.id.messageEditText).text.toString(),
time = nowTime
)
chatDB?.push()?.setValue(chatItem)
}
}
}
<?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">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/chatRecyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/messageEditText"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<EditText
android:id="@+id/messageEditText"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/sendButton"
app:layout_constraintStart_toStartOf="parent" />
<Button
android:id="@+id/sendButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:backgroundTint="@color/orange"
android:text="전송"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
package com.example.aop_part3_chapter14.chatdetail
import android.os.SystemClock
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.aop_part3_chapter14.databinding.ItemChatBinding
import com.example.aop_part3_chapter14.databinding.ItemChatListBinding
import java.text.SimpleDateFormat
import java.util.Date
class ChatItemAdapter : ListAdapter<ChatItem, ChatItemAdapter.ViewHolder>(diffUtil) {
// ViewBinding을 통해 레이아웃에서 가져옴
inner class ViewHolder(private val binding: ItemChatBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(chatItem: ChatItem) {
binding.senderTextView.text = chatItem.senderId
binding.messageTextView.text = chatItem.message
binding.timeTextView.text = chatItem.time
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(
ItemChatBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(currentList[position])
}
companion object {
val diffUtil = object : DiffUtil.ItemCallback<ChatItem>() {
// 현재 노출되고 있는 아이템과 새로운 아이템이 같은지 확인 ㅡ, 새로운 아이템이 들어오면 호출됨
// 일반적으로 키값을 통해 구분하게 됨
override fun areItemsTheSame(oldItem: ChatItem, newItem: ChatItem): Boolean {
return oldItem.time == newItem.time
}
// 현재 아이템과 새로운 아이탬의 = 여부를 확인
override fun areContentsTheSame(oldItem: ChatItem, newItem: ChatItem): Boolean {
return oldItem == newItem
}
}
}
}
<?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="wrap_content"
android:padding="10dp">
<TextView
android:id="@+id/senderTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="15sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="NICKNAME" />
<TextView
android:layout_marginStart="10dp"
android:id="@+id/messageTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textColor="@color/black"
android:textSize="15sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/senderTextView"
app:layout_constraintTop_toTopOf="parent"
tools:text="MESSAGE" />
<TextView
android:id="@+id/timeTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/timeColor"
android:textSize="10sp"
app:layout_constraintEnd_toEndOf="@id/messageTextView"
app:layout_constraintTop_toBottomOf="@+id/messageTextView"
tools:text="NICKNAME" />
</androidx.constraintlayout.widget.ConstraintLayout>
package com.example.aop_part3_chapter14.chatdetail
data class ChatItem (
val time: String,
val senderId: String,
val message: String
){
constructor():this("","","")
}
package com.example.aop_part3_chapter14
class DBKey {
companion object{
const val DB_ARTICLES = "Articles"
const val DB_USERS = "Users"
const val CHILD_CHAT = "Chat"
const val DB_CHATS = "Chats"
}
}