앱 프로젝트 - 14 - 1 (중고거래 앱) - Firebase Storage, BottomNavigationView ( 네비게이션 뷰 ), Fragment, ViewBinding, FloatingActionButton, SnackBar( Toast 다른 버전 ), ProgressBar, 채팅 기능, Firebase Authentication 보안규칙, 사진 Content Provider, URI와 URL의 차이점?, Button selector

하이루·2022년 3월 5일
0

소개


레이아웃 소개


알아야 할 내용

SnackBar -> Toast 다른 버전

Toast와 거의 동일하지만 생김새가 다르다.

예시

  Snackbar.make(view,"로그인 후 사용해주세요", Snackbar.LENGTH_LONG).show()

결과 화면


URI와 URL

URI (Uniform Resource Identifier)

  • 특정 리소스를 식별하는 통합 자원 식별자(Uniform Resource Identifier)를 의미한다.

URL (Uniform Resource Locator)

  • 웹주소를 의미하며, 해당 리소스가 어디에 있는지를 나타내준다.

  • 인터넷에서 웹 페이지, 이미지, 비디오 등 리소스의 위치를 가리키는 문자열이다.

  • URI의 서브넷으로 URI의 역할에서 웹주소에 특화되어 있는 영역이다.

[ 즉, URI가 URL보다 상위의 개념이다. ]

URI와 URL의 구분을 돕기위해 설명하자면

예를 들어

  • 나의 인형의 이름이 "미스 포츈"이라고 한다면,
    이 "미스 포츈"이라는 명칭은 식별자(Identifier)로써
    나의 특정 인형을 특정하도록 해주므로 URI이다.

    하지만 "미스 포츈" 만으로는 이 인형의 위치를 식별할 수 없으므로 URL은 될 수 없다.

  • 반면에 "미스 포츈"의 위치인 "서울 동작구 사당동"은 "미스 포츈"의 위치를 나타내므로
    URL이자 URI이다.

즉, URI는 식별자이고,
URL은 주소( 위치정보 )를 통한 식별자이다.

이런 부분들을 적용시켜본다면

아래의 두 주소는 모두 www.homepage.com 웹서버 내부의 index.html을 가리키고 있다.

  • www.homepage.com/index.html
  • www.homepage.com/index
  1. 첫번째 주소의 경우, ( www.homepage.com/index.html )
    웹서버의 실제 주소를 나타내므로 URI이자 URL로 볼 수 있다.
    -> URI : O , URL : O

  2. 두번째 주소의 경우, ( www.homepage.com/index )
    실제로 index라는 파일이 해당 웹서버 내에 존재하지 않으므로 URL이라고 볼 수 없다.
    하지만 웹서버 내에서 이를 처리하여 결과적으로 index.html을 식별해주기 때문에 URI라고 볼 수 있다.
    -> URI : O , URL : X


Firebase Storage

--> 이미지 파일과 같이 파일, 동영상 파일등을 저장할 수 있는 저장고

Firebase 세팅

  1. 해당 글 참조 ( 프로젝트 등록 ) : 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

  2. 프로잭트에서 앱 수준의 gradle에 Firebase Storage에 대한 의존성 추가

    implementation 'com.google.firebase:firebase-storage-ktx'
    ㅡ 이 코드 아래에 추가해줘야 함

Firebase Storage 사용하기

예시)

    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()
                }
            }
    }

1. Firebase Storage 라이브러리를 불러옴

    private val storage: FirebaseStorage by lazy {
        Firebase.storage
    }
    

2. 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)
      • putFile()메소드는 Storage에 데이터를 넣는 작업을 완료한 후,
        작업 결과를 나타내기 위한 변수를 반환한다.
        --> 이 변수를 바탕으로 아래의 addOnCompliteListener를 통해 작업 결과에 따른 처리를 해주는 것이다.
  • 이 부분에서 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타입으로 할당되어 있음

3. Firebase Storage에서 데이터를 가져옴

 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
    • downloadUrl에 해당 파일의 URL가 들어있으므로 그것을 가져옴
  • 이 부분에서 Firebase Storage에서 데이터를 가져오는 작업의 성공 여부에 대한 처리를 해줌

     storage.reference.child("article/photo").child(fileName)
        .downloadUrl
        .addOnSuccessListener { uri ->
              successHandler(uri.toString())
        }
        .addOnFailureListener {
              errorHandler()
        }
    • 데이터를 가져오는 작업이 성공해서 downloadUrl에 URL이 들어있다면
      addOnSuccessListener의 람다함수를 열어서
      해당 URL을 람다함수의 파라미터로 하여 람다함수 실행
      ( 파일을 불러온 것에 성공했으므로 이에 대한 처리를 해줌 )

    • 데이터를 가져오는 작업이 실패해서 downloadUrl에 URL이 들어있지 않다면
      addOnFailureListener의 람다함수를 열어서
      실패에 대한 처리를 해줌


ProgressBar

만약에 작업이 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"/>
  • 작업이 시작될 때, ProgressBar의 visibility를 visible로 바꾸고,
  • 작업이 완료되는 시점에 ProgressBar의 visibility를 gone으로 바꾸면 된다.

Firebase Realtime Database

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-

데이터 모델( Data class )를 DB에 통채로 넣고 빼기

Data Model 예시 ( ArticleModel.kt )

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,"","")
}
  • Data class를 통채로 DB에 넣고 빼고 싶을 때는 아래와 같이 Data class에 빈 생성자를 반드시 만들어줘야 한다.
    ......
    
    ){
       constructor(): this("","",0,"","")
    }

DB에 데이터 모델 통채로 넣기

    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 = ""
       )
    • 위에서 data class로 만든 데이터 모델을 구현한 것임
      --> DB에는 해당 데이터 모델이 통채로 들어가게 됨
  • 이 부분에서 데이터 모델을 DB에 넣고 있음

       articleDB
          .push()
          .setValue(articleModel)
      1. push() 메소드를 사용한 후,
      1. setValue() 메소드의 파라미터로 넣을 데이터 모델을 넣으면
      1. DB에 데이터 모델이 들어감
  • 해당 코드를 통해 DB에 통채로 들어간 데이터 모델 예시

    • 각 데이터 모델은 각자의 식별 Key를 가지고 DB에 들어가게 되며,
      위의 예시와 같이 해당 식별 Key의 Value로 데이터 모델에 담긴 데이터가 들어가게 됨

    • 따라서 데이터 모델을 통채로 DB에 넣는 방식을 사용한 경우,

      DB에서 데이터를 꺼낼 때도 데이터 모델을 통채로 꺼내서
      데이터 모델에서 필요한 데이터를 뽑아내는 방식으로 사용하는 것을 추천함

DB에서 데이터 모델 통채로 가져오기

  • DB에서 데이터 모델의 형식으로 통채로 꺼내는 작업은 데이터 모델을 통채로 DB에 넣었던 데이터에만 가능하다. ( 식별 Key 때문 )
    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)
  • 이 부분에서 Firebase DB에서 원하는 위치까지 접근
       private lateinit var articleDB: DatabaseReference
      articleDB = Firebase.database.reference.child(DB_ARTICLES)
  • 이 부분에서 데이터 모델들을 담기 위한 mutableList를 생성
       private val articleList = mutableListOf<ArticleModel>()
    • 데이터 모델들을 담기 위한 리스트이므로 제네릭타입은 데이터 모델의 타입
    1. ChildEventListener를 통해 데이터 가져오기
       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)
    1. ValueEventListener를 통해 데이터 가져오기
       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

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-

Authentication 활용

  • 앱 차원에서 Firebase Authentication 기능을 통해 로그인 할 경우,
    해당 앱의 Firebase.auth에 로그인 정보가 할당되어 있게 된다.

    이것은 액티비티 차원이 아니라 앱 차원에서 라이브러리에 할당되는 데이터이기 때문에
    로그인 여부에 따른 처리를 해주고 싶은 경우

    다른 것 신경쓸 것 없이 Firebase.auth의 currentUser 가 null인지 아닌지 여부만 확인해주면 된다.

     private val auth: FirebaseAuth by lazy {
          Firebase.auth
      }
      
      if(auth.currentUser == null)
      // 로그인 되어있지 않을 때 실행
      
      if(auth.currentUser != null)
     // 로그인 되어있을 때 실행

Firebase의 다른 기능들과 Authentication 연동하기

  • 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 작업을 허락한다.

FloatingActionButton

FloatingActionButton 예시

    <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

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

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


사진 가져오기

Content Provider : https://velog.io/@odesay97/%EC%95%B1-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-05-1-%EC%A0%84%EC%9E%90%EC%95%A1%EC%9E%90-Layout%ED%99%94%EB%A9%B4-%EA%B0%80%EB%A1%9C-%EA%B7%B8%EB%A6%AC%EA%B8%B0-Android-Permission-View-Animation-Activity-Lifecycle-%EC%95%A1%ED%8B%B0%EB%B9%84%ED%8B%B0%EC%9D%98-%EC%83%9D%EB%AA%85%EC%A3%BC%EA%B8%B0-Content-Provider-SAF-Storage-Access-Framework-%EB%A1%9C%EC%BB%AC-%EC%9D%B4%EB%AF%B8%EC%A7%80%EC%82%AC%EC%A7%84-%EA%B0%80%EC%A0%B8%EC%98%A4%EA%B8%B0timer-%EC%93%B0%EB%9E%98%EB%93%9C-%ED%99%94%EB%A9%B4-%EA%B0%80%EB%A1%9C%EB%A1%9C-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0


Selector - 버튼이 클릭되었을 때, 클릭되지 않은 상태일 때 설정

  • 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색으로 표현된다.

2. BottomNavigationView가 들어갈 전체 Activity

  • 전체 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

BottomNavigationView 예시

BottomNavigationView 구성 설명

BottomNavigationView는 3가지의 합작으로 만들어진다.

  1. BottomNavigationView가 들어갈 전체 Activity

  2. BottomNavigationView의 메뉴 -> ( 위의 예시에서 홈 / 채팅 / 나의 정보 )

  3. BottomNavigationView에서 선택된 메뉴에 따라 화면에 나타날 Fragment 화면

추가)
4. 탭이 선택되었을 때와 선택되지 않았을 때의 탭 색깔 설정

각 구성에 대한 구현

1. BottomNavigationView의 메뉴 구성 ( 탭 리스트 )

  • res 폴더 내부에 menu폴더를 만들어서 menu태그를 구현할 xml 파일로 BottomNavigationView에 들어갈 탭들의 리스트 구성

  • 탭은 item태그로 만들면 되며 그 속성은 다음과 같다.

    • item태그의 id는 식별자
    • item태그의 icon은 메뉴에서 나타날 아이콘
    • item태그의 title은 메뉴에서 나타날 글자를 의미한다.
    <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>

1-2. 탭의 색 구성

  • 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색으로 표현된다.

2. BottomNavigationView가 들어갈 전체 Activity

  • 전체 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의 영역을 지정해줌

      • 해당 FrameLayout에 Fragment가 변경되며 들어갈 것임
        ( 즉, 이 FrameLayout은 Fragment의 크기를 나타내며ㅡ, Fragment로 변경될 더미 View라고 볼 수 있음 )
    • 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()
      • 아래의 3번에서 구현한 Fragment들을 이런 식으로 메인 Activity에 생성하여 사용하면 된다.
    • 이 부분에서 사용자가 탭을 선택할 때마다 그 탭에 맞는 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로 대체하는 작업을 진행함

          1. beginTransaction()으로 Transaction을 열었음
          2. replace() 메소드를 통해 FrameLayout을 사용자가 선택한 fragment로 대체함
          3. commit()을 통해 해당 transaction을 확정 및 종료함
        • 여기서 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)
        

3. BottomNavigationView에서 선택된 메뉴에 따라 화면에 나타날 Fragment 화면

  • Fragment에 대한 자세한 부분은 아래에 정리해 놓았다.

  • 여기서는 BottomNavigationView에서의 Fragment 활용에 좀 더 집중하여 설명한다.

BottomNavigationView에서 Fragment의 원리를 설명하자면

2번에서 FrameLayout의 영역이 위의 빨간줄 쳐놓은 부분인데,
파란색으로 해 놓은 네비게이션뷰의 메뉴들을 클릭하면
FrameLayout이 Fragment로 대체되어 화면에 나타나게 되는 것이다.

따라서

  • 각 메뉴에 대응되는 Fragment를 각각 만들어야 한다.
    -> 예를 들어 위의 예시에는 홈, 채팅, 나의 정보라는 3개의 탭이 있으므로,
    홈을 눌렀을 때 나타날 Fragment,
    채팅을 눌렀을 때 나타날 Fragment,
    나의 정보을 눌렀을 때 나타날 Fragment를
    각각 구성해야 한다.

위의 홈 탭에 대한 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이기 때문에 고려해야할 점들

    1. 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() 사용해서 가져오는 것이 편하다.

    2. 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]으로 유지 된다.

    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)
         }
      }   

Fragment

--> 위에 BottomNavigationView는 각 탭에 Fragment를 올려서 사용한다.

Fragment란?

  • Activity와는 다르게
    재사용 가능한 부분을 짤라서
    독립적인 수명주기를 가진 레이아웃으로 만든 것이
    Fragment 이다.

Fragment의 특징?

  • Fragment는 자체적인 입력 이벤트를 처리할 수 있음

  • Fragment는 독립적인 수명주기를 가짐

  • 하지만 Fragment 혼자 존재할 수는 없음
    --> 항상 Activity 위에서 존재해야 함

  • 재사용성이 있는 View모음으로 CustomView를 만들었던 것처럼,
    비슷한 역할을 하는 View모음을 Fragment로 만들어 놓으면
    좀 더 편리하게 재사용할 수 있음
    --> 이 경우 UI의 모듈성이 올라가기 때문에 좀 더 쉽게 재사용 할 수 있음

Fragment의 생명주기( Lifecycle )

Activity가 Lifecycle를 가진 것처럼
Fragment도 Activity와는 다른, 하지만 비슷한 Lifecycle을 가지고 있음

공식문서 : https://developer.android.com/guide/fragments/lifecycle

  • Fragment의 생명주기를 위와 같이 Fragment자체와 View로 나눠서 보는 이유는
    Fragment가 시작메소드로 onViewCreated()를 사용하며,
    이에 따라 View의 생명주기와 Fragment 자체의 생명주기를 고려한 코딩을 해줘야하기 때문이다.

    • Fragment에서도 onCreate()를 사용할 수 있지만,
      Fragment의 onCreate() 작업은 해당 Fragment를 사용하는 Acitivity의 onCreate()의 작업이 완료되기 전에 완료되므로 (위계상),
      Fragment에서 onCreate()를 사용하여 변수할당등의 작업을 진행할 경우 crash가 발생할 수 있다.
  • 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()가 호출되어 이 리스너가 다시 할당될 것이다.

        • 이때 리스너가 이미 할당되어 있는 경우였다면 중복할당에 해당 된다.
          --> 즉, 해당 이벤트에 대해 중복된 리스너 만큼의 반응이 돌아오는 것
          --> 예를 들어 리스너가 5번 중복된 상황에서 이벤트가 발생할 경우,
          해당 리스너의 내용이 5번 연속해서 호출된다.

        onViewDestroy() 메소드를 활용하여 Fragment가 화면에 벗어난 시점에서 기존의 리스너의 자원을 반환해주는 것으로 자원관리를 하였다.

Fragment 사용

--> 위의 BottomNavigationView에서 Fragment 사용에 대한 더 자세한 설명들이 있음

  1. 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가 만들어지는 시점에 호출되는 메소드를 통해 자원을 할당하는 것으므로 자원관리 또한 해줘야 한다. )
          -> 이에 대한 것은 위의 생명주기에서 다뤘음

  2. 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의 작업은 다음과 같다.

          1. beginTransaction()으로 SupportFragmentManager에서
            Transaction을 열었음 ( Transaction 작업 시작 )

          2. replace() 메소드를 통해 FrameLayout을 사용자가 선택한 fragment로 대체함

            • SupportFragmentManager의 멤버함수인 replace()는
              첫번째 파라미터로 대체 될 View 혹은 Layout을 받고
              두번째 파라미터로 대체하여 들어갈 Fragment을 받는다.

              --> 즉, 여기서는 FrameLayout을 fragment로 대체한 것이다.

          3. commit()을 통해 해당 Transaction을 내용을 확정 및 종료함


코드 소개

파일 구성

MainActivity.kt -> BottomNavigationView를 담기 위한 Activity

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()
            }        
    }

}

activity_main.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>

bottom_navigation_menu.xml -> BottomNavigationView에 들어갈 탭을 모아놓은 xml파일

<?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은 나타날 글자를 의미한다.-->

selector_menu_color.xml -> BottomNavigationView에서 선택된 탭과 그렇지 않은 탭의 색을 지정해서 모아놓은 xml파일

<?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-->

Home탭 관련

HomeFragment.kt

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)
    }
}

fragment_home.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"
    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>

ArticleAdapter.kt -> HomeFragment에서 사용하는 RecyclerView에 사용할 Adapter

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
            }
        }
    }
}

item_article.xml -> HomeFragment의 RecyclerView에 들어갈 item에 대한 레이아웃

<?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>

AddArticleActivity.kt -> HomeFragment에서 FloatingActionButton을 클릭하면 나타나는ㅡ, 데이터를 추가하기위한 Activity

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()

    }
}
  • 2초정도 소요되는 등 소요시간이 긴 작업에 대해
    사용자가 당황하지 않도록 작업이 진행중이라는 것을 ProgressBar를 나타내어
    보여주고 있다.

activity_add_article.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"
    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>
  • ProgressBar의 visibility를 gone으로 설정해놓고,
    ProgressBar가 필요한 오래걸리는 작업에 대해서 visibility부분만 설정을 바꾸는 것으로
    ProgressBar를 나타났다 사라졌다 하게 만들 수 있다.

ArticleModel.kt --> DB와 주고 받을 데이터의 덩어리 단위를 구성하는 파일

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 예외처리 때문인 듯 함 )

MyPage탭 관련

MyPageFragment

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 = "로그아웃"
    }
}

fragment_mypage.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"
    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>

Chat 탭 관련

ChatListFragment.kt

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()
    }
}

fragment_chatlist.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"
    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>

ChatListAdapter.kt -> ChatListFragment에서 사용하는 RecyclerView에 사용할 Adapter

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
            }
        }
    }
}

item_chat_list.xml -> ChatListFragment의 RecyclerView에 들어갈 item에 대한 레이아웃

<?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>

ChatListItem.kt --> DB와 주고 받을 데이터의 덩어리 단위를 구성하는 파일

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)
}

채팅방 관련

ChatRoomActivity.kt

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)
        }
    }
}

activity_chatroom.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"
    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>

ChatItemAdapter.kt -> ChatRoomFragment에서 사용하는 RecyclerView에 사용할 Adapter

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
            }
        }
    }
}

item_chat.xml -> ChatRoomFragment의 RecyclerView에 들어갈 item에 대한 레이아웃

<?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>

ChatItem.kt --> DB와 주고 받을 데이터의 덩어리 단위를 구성하는 파일

package com.example.aop_part3_chapter14.chatdetail

data class ChatItem (
    val time: String,
    val senderId: String,
    val message: String
        ){
    constructor():this("","","")
}

DBKey.kt -> Realtime Database의 각 reference에 대한 이름을 정리해 놓은 Class

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"
    }
}
profile
ㅎㅎ

0개의 댓글