티스토리 뷰

1. 안드로이드 중고 거래 앱 만들기 (With kotlin)

코틀린으로 안드로이드 중고 거래 앱을 만들어 보았습니다. 사용자는 이메일과 비밀번호로 회원가입 및 로그인 하게 되며 이후 물건과 가격 물건 사진으로 게시글을 등록할 수 있으며 제품마다 상품 게시자와 채팅할 수 있는 채팅방 목록과 채팅 또한 구현합니다.

프래그먼트뷰를 사용해서 하단의 메뉴바를 통해서 fragment 를 전환하는 방식으로 앱을 동작합니다. 파이어베이스 storage 기능을 사용하여 이미지를 업로드하고 실제로 받아와서 게시글 목록에 뿌려주는 작업 또한 실습해 보았습니다.

또 사용자는 중고 판매 게시글을 올릴때 플로팅 액션 버튼을 사용해서 제품 게시글을 올릴 수 있습니다.

주요 기능

  • 이메일로 회원가입 및 로그인/로그아웃
  • 사진 포함 중고 거래 항목을 게시글로 업로드 가능
  • 회원 간의 채팅 기능 등.

사용 기술

  • RecyclerView
  • View Binding (뷰바인딩)
  • Fragment
  • BottomNavigationView
  • Firebase Storage
  • Firebase Realtime Database
  • Firebase Authentication
  • snackBar
  • selector
  • registerForActivityResult
  • ActivityResultContracts.GetContent() 등.

결과 화면

 

2. 기본 레이아웃 구성

메인 엑티비티에는 메인으로 프레임 레이아웃을 주고 아래에 하단 네비게이션 뷰를 주어서 메뉴를 달아서 사용자가 메뉴를 변경할 때마다 프레임 레이아웃에 해당 되는 프레그 먼트를 뿌려주게됩니다. 물론 초기 실행시에는 홈 프레그먼트를 기본으로 열리게 설정합니다.

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

    <FrameLayout
        android:id="@+id/fragmentContainer"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toTopOf="@id/navigationView"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

    </FrameLayout>

    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:id="@+id/navigationView"
        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>

아래 처럼 각각 Fragmet 클래스를 정의해줍니다. 또한 각각 레이아웃에 해당하는 xml 파일도 만들어야합니다. 이때 Fragment 를 상속 받고 내부에는 프레그먼트 레이아웃 자원을 넣어줍니다.

class HomeFragment : Fragment(R.layout.fragment_home) {}

class ChatListFragment : Fragment(R.layout.fragment_chatlist) {}

class MyPageFragment : Fragment(R.layout.fragment_mypage) {}

2.1. menu 구성

메뉴 구성은 아래와 같이 xml 코드를 추가해서 생성해주시면 되겠습니다. 해당 프로젝트에서는 하단 메뉴 3개를 사용할 것이기 때문에 각각 홈, 채팅 리스트, 마이 페이지로 구성된 메뉴 아이템을 추가해주었습니다. 아이콘의 경우 각각 적당한 자원을 추가해서 사용하면됩니다.

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:id="@+id/home"
        android:title="@string/home"
        android:icon="@drawable/ic_baseline_home_24"
        />
    <item android:id="@+id/chatList"
        android:title="@string/chatting"
        android:icon="@drawable/ic_baseline_chat_24"
        />
    <item android:id="@+id/myPage"
        android:title="@string/my_info"
        android:icon="@drawable/ic_baseline_person_pin_24"
        />

</menu>

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>

이후 BottomNavigationView 코드 부분을 다시 보면, 아래와 같이 사용할 수 있습니다. itemRippleColor 로 리플 효과를 삭제, menu 속성으로 메뉴를 지정, itemIconTint 로 메뉴 선택 시 색상을 설정해주었습니다.

<com.google.android.material.bottomnavigation.BottomNavigationView
        android:id="@+id/navigationView"
        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" />

2.2 MainActivity 에서 Fragment 전환 하면서 보여주기

        val homeFragment = HomeFragment()
        val chatListFragment = ChatListFragment()
        val myPageFragment = MyPageFragment()

먼저 위에서 정의해주었던 프레그먼트를 각각 생성하여 초기화 해줍니다.

    private fun replaceFragment(fragment: Fragment) {
        supportFragmentManager.beginTransaction() // 트랜젝션 : 작업을 시작한다고 알려줌;
            .apply {
                replace(R.id.fragmentContainer, fragment)
                commit() // 트랜잭션 끝.
            }
    }

다음엔 replaceFragmet 라는 함수를 하나 만들고 프래그먼트를 인자로 전달받아 내부에서 supportFragmentManager 를 사용해 replace 해주는 트랜잭션 커밋을 적용하여 프레그먼트를 전환할 수 있습니다.

        val bottomNavigationView = findViewById<BottomNavigationView>(R.id.navigationView)

        replaceFragment(homeFragment) // 최초 홈 설정.

        // 네비게이션 버튼 리스너;
        bottomNavigationView.setOnNavigationItemSelectedListener {
            when (it.itemId) {
                R.id.home -> replaceFragment(homeFragment)
                R.id.chatList -> replaceFragment(chatListFragment)
                R.id.myPage -> replaceFragment(myPageFragment)
            }
            true
        }

이제 네비게이션 뷰에 리스너를 하나 달아서 메뉴가 변경 될 때마다 어떤 메뉴가 선택 되었는지에 따라서 각각 프레그 먼트를 전환해주면 구현 완료입니다. 최초 앱 실행시는 홈 프레그먼트를 보여주도록 했습니다.

3. Firebase Authentication

저번에 틴더 등의 프로젝트에서도 사용했었던 파이어 베이스 인증 기능입니다. 해당 기능을 사용하면 로그인 기능을 간편하게 구현할 수 있었죠. 이번에는 간단히만 보고 넘어가도록 하겠습니다.

    private val auth: FirebaseAuth by lazy {
        Firebase.auth
    }

먼저 파이어베이스 auth 를 선언 및 게으른 초기화를 해줍니다. (게을르게 초기화 해주면 실제 사용이 되는 시점에 부랴부랴 초기화를 딱 해주게 되겠죠.)

3.1. 회원가입 버튼

        binding.signUpButton.setOnClickListener {

            val email = binding.emailEditText.text.toString()
            val password = binding.passwordEditText.text.toString()

            auth.createUserWithEmailAndPassword(email, password)
                .addOnCompleteListener(requireActivity()){ task->
                    if(task.isSuccessful){
                        Toast.makeText(context, "회원가입에 성공했습니다. 로그인 버튼을 눌러주세요.", Toast.LENGTH_SHORT)
                            .show()
                    }else{
                        Toast.makeText(context, "회원가입에 실패했습니다. 이미 가입된 이메일일 수 있습니다.", Toast.LENGTH_SHORT)
                            .show()
                    }

                }
        }

회원 가입은 입력받은 이메일과 패스워드를 사용하여 auth.createUserWithEmailAndPassword 로 진행할 수 있으며 컴플리트 리스너를 달아서 성공한 경우와 실패한 경우를 각각 처리해줄 수 있습니다.

로그인

                auth.signInWithEmailAndPassword(email, password)
                    .addOnCompleteListener(requireActivity()){ task ->
                        if(task.isSuccessful){
                            successSignIn()
                        }else{
                            Toast.makeText(context, "로그인에 실패했습니다. 이메일 또는 비밀번호를 확인해주세요.", Toast.LENGTH_SHORT)
                                .show()
                        }
                    }           

로그인은 signInWithEmailAndPassword 로 진행할 수 있고 마찬가지로 리스너를 달아서 성공한 경우 및 실패한 경우를 처리해주었습니다.

로그아웃

auth.signOut()

로그아웃의 경우 위와 같이 해줍니다.

4. FloatingActionButton

플로팅 액션 버튼은 화면 z축 최상위에 고정되어 항상 떠있는 것처럼 사용할 수 있는 버튼으로 이미 많은 앱에서 사용되어지고 있어 여러분들도 본적이 있을 겁니다.

fragment_home.xml 일부
    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/addFloatingButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="16.18dp"
        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" />

플로팅 버튼은 FloatingActionButton 으로 추가 할 수 있습니다.

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        Log.d("sslee", "onViewCreated")

        val fragmentHomeBinding = FragmentHomeBinding.bind(view)
        binding = fragmentHomeBinding

        articleList.clear() //리스트 초기화;

        initDB()

        initArticleAdapter(view)

        initArticleRecyclerView()

        initFloatingButton(view)

        // 데이터 가져오기;
        initListener()
        articleDB.addChildEventListener(listener)
    }

이후 기능 구현은 onViewCreated 를 통해서 해주어야하는데 이는 액티비티가 onCreate() 에서 초기 작업을 해주는 것과 같다고 생각될 수 있으나 사용되는 함수는 다르다는 것을 기억해주셔야합니다. (프래그먼트라서 onViewCreated)

// 플로팅 버튼
        binding!!.addFloatingButton.setOnClickListener {
            context?.let {
                if (auth.currentUser != null) {
                    val intent = Intent(it, AddArticleActivity::class.java)
                    startActivity(intent)
                } else {
                    Snackbar.make(view, "로그인 후 사용해주세요", Snackbar.LENGTH_LONG).show()
                }
            }
        }

이후 플로팅 버튼 클릭 리스너를 달아 구현해서 사용하면 되겠습니다. 해당 프로젝트에서는 새로운 물품을 올리는 엑티비티를 새로 띄워서 유저에게 보여주도록 하였고 로그인이 되지 않은 상태라면 로그인을 요구하는 스낵바를 띄웁니다.

4.1. 게시글 전용 RecyclerView 구현 해보기

리사이클러뷰를 실습하면서 정말 많이 사용해보는 것 같습니다. 이전 프로젝트에서 자세한 설명을 하였기에 이번에는 대략적인 코드만 간단하게 올리면서 보도록 하겠습니다. 절차를 보면서 순서를 익히는게 좋은 것 같아요.

4.1.1. 데이터 클래스 정의

data class ArticleModel(
    val sellerId: String,
    val title: String,
    val createdAt: Long,
    val price: String,
    val imageUrl: String
) {
    // 파이어베이스에 클래스 단위로 올리려면 인자빈생성자 필요;
    constructor() : this("", "", 0, "", "")
}

먼저 사용된 데이터 클래스를 하나 적용해주는데 여기서 처음보는 개념은 바로 빈 인자를 갖는 생성자를 주는 것인데요? 왜 이렇게 해야하냐면 이후에 파이어베이스 리얼타임 데이터 베이스에 객체 단위로 업로드를 해주려면 이렇게 빈 생성자가 필요해서 작성해준 것 입니다.(중요)

4.1.2. 뷰 아이템 레이아웃 xml 정의

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout...>

    <ImageView
        android:id="@+id/thumbnailImageView"
        ... />

    <TextView
        android:id="@+id/titleTextView"
        ...
        android:maxLines="2"
        .../>

    <TextView

        android:id="@+id/dateTextView"
        ... />

    <TextView
        android:id="@+id/priceTextView"
       ... />

    <View
        android:layout_width="0dp"
        android:layout_height="1dp"
        android:background="@color/gray_ec"
        .../>

</androidx.constraintlayout.widget.ConstraintLayout>

리사이클러뷰에 아이템으로 할당될 레이아웃을 하나 정의합니다. 크게 특이한 부분은 없으며 가장 아래 view를 하나 추가한 것은 구분 선을 하나 주기위해서 인위적으로 추가해준 것입니다.

4.1.3. Adapter 정의

class ArticleAdapter(val onItemClicked: (ArticleModel) -> Unit) : ListAdapter<ArticleModel, ArticleAdapter.ViewHolder>(diffUtil) {

    inner class ViewHolder(private val binding: ItemArticleBinding) :
        RecyclerView.ViewHolder(binding.root) {

        @SuppressLint("SimpleDateFormat")
        fun bind(articleModel: ArticleModel) {

            // Long 형식에서 날짜로 바꾸기.
            val format = SimpleDateFormat("MM월 dd일")
            val date = Date(articleModel.createdAt) // Long -> Date

            binding.titleTextView.text = articleModel.title
            binding.dateTextView.text = format.format(date).toString()
            binding.priceTextView.text = articleModel.price

            // glide로 이미지 불러오기;
            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 {
                // equals 비교;
                return oldItem == newItem

            }

        }
    }
}

다음은 어댑터 정의입니다. 리스트 어뎁터를 사용했으며 diffUtil 은 동일한 아이템을 비교하기 위한 매커니즘을 정의해줍니다.

bind() 에서는 우리가 정의해준 모델 클래스를 받아와서 뷰에 바인딩해줍니다. 각각 제목, 가격, 날짜를 대입해주고 있으며 날짜의 경우 포멧을 사용해서 변환해줍니다. 이미지는 glide 라이브러리를 사용해서 불러와주고 아이템 클릭 리스너는 해당 어뎁터 생성자에서 람다로 받아 처리합니다.

뷰바인딩의 개념도 사용되어 뷰홀더를 생성할 때 바인딩을 사용하는 것을 확인할 수 있습니다.

클릭 리스너 에서는 해당 게시글을 위한 채팅방을 개설하여 사용자에게 알려줍니다. 절차는 먼저 눌른 아이템이 내가 올린 아이템이 아니라면 나의 id 및 상대방의 id 에 해당하는 데이터 베이스 아래에 현재시간(임시)을 키로 갖는 채팅방을 하나 개설합니다.

4.1.4. 생명주기 활용

프레그먼트의 생명주기를 활용하여 메뉴가 변경되었다가 다시 돌아올 경우 다시 그려주도록 노티파이를 적용. 그리고 Destory 시 이벤트 리스너를 제거해주고 다시 생성될 때 새로 할당되도록 해줍니다.

    override fun onDestroy() {
        super.onDestroy()

        articleDB.removeEventListener(listener)
    }

    @SuppressLint("NotifyDataSetChanged")
    override fun onResume() {
        super.onResume()

        articleAdapter.notifyDataSetChanged() // view 를 다시 그림;
    }

5. 갤러리에서 이미지 가져오기 SAF

강좌를 보면 startActivityForResult() 를 사용해서 getContent 인텐트를 실행하지만 이는 사실 deprecated 되어 삭선처리 되어 사용을 권장하고있지는 않더군요.. 그래서 최신의 코드를 적용하여 다시 코딩하였습니다. (권한 부여 코드는 이전에 다뤘기에 생략합니다.)

    private val getContent = registerForActivityResult(ActivityResultContracts.GetContent()){ uri->

        if (uri != null) {
            // 사진을 정상적으로 가져온 경우;
            findViewById<ImageView>(R.id.photoImageView).setImageURI(uri)
            selectedUri = uri
        } else {
            Toast.makeText(this, " 사진을 가져오지 못했습니다.", Toast.LENGTH_SHORT)
                .show()
        }
    }

사진을 가져오는 엑티비티를 하나 등록하는데 registerForActivityResult 를 사용하여 GetContent()를 지정 그리고 람다로 uri 을 받아 처리하는 코드를 작성합니다. 해당 코드라인은 클래스안에 멤버 혹은 onCreate 부분에 작성하면 오류없이 실행가능합니다.

    private fun startContentProvider() {
        // 이미지 SAF 기능 실행; 이미지 가져오기;

        // old ver.
        //val intent = Intent(Intent.ACTION_GET_CONTENT)
        //intent.type = "image/*"
        // startActivityForResult(intent, 2020) // deprecated

        // new ver.
        getContent.launch("image/*")
    }

이후에는 그냥 getContent.launch() 로 실행이 가능하며 이미지 로드를 해줄 것이기 때문에 이미지 그리고 확장자는 제한없이 모두 읽어들이도록 하여 이미지를 가져오게 할 수 있습니다. 이렇게 하니 requestCode 도 따로 필요 없고 더 간결해진 느낌이네요.

또는 아래 코드를 통해서 인텐트를 결과로 받아와서 처리해줄 수 있습니다. 더 자세한 내용은 안드로이드 공식 래퍼런스를 참고하세요.

val startForResult = registerForActivityResult(StartActivityForResult()) { result: ActivityResult ->
    if (result.resultCode == Activity.RESULT_OK) {
        val intent = result.data
        // Handle the Intent
    }
}

override fun onCreate(savedInstanceState: Bundle) {
    // ...

    val startButton = findViewById(R.id.start_button)

    startButton.setOnClickListener {
        // Use the Kotlin extension in activity-ktx
        // passing it the Intent you want to start
        startForResult.launch(Intent(this, ResultProducingActivity::class.java))
    }
}

5.1. Firebase Storage image upload

    private fun uploadPhoto(uri: Uri, successHandler: (String) -> Unit, errorHandler: () -> Unit) {
        var fileName = "${System.currentTimeMillis()}.png"
        storage.reference.child("article/photo").child(fileName)
            .putFile(uri)
            .addOnCompleteListener {
                if (it.isSuccessful) { // 업로드 과정 완료
                    // 다운로드 url 가져오기
                    storage.reference.child("article/photo").child(fileName).downloadUrl
                        .addOnSuccessListener { uri ->
                            successHandler(uri.toString())
                        }.addOnFailureListener {
                            errorHandler()
                        }
                } else {
                    Log.d("sslee", it.exception.toString())
                    errorHandler()
                }
            }
    }

SAF 를 통해 가져온 이미지 uri 를 파이어 베이스 스토리지에 업로드 해줍니다. 파일명은 현재 시간 밀리초.png 로 설정하고 uri 를 업로드하여 이미지를 업로드합니다. 업로드에 성공한 경우에는 아티클 또한 업로드 해주는데.

                val photoUri = selectedUri ?: return@setOnClickListener
                uploadPhoto(photoUri,
                    successHandler = { url -> // 다운로드 url 을 받아서 처리;
                        uploadArticle(sellerId, title, price, url)
                    },
                    errorHandler = {
                        Toast.makeText(this, "사진 업로드 실패.", Toast.LENGTH_SHORT)
                            .show()
                        hideProgress()
                    })

업로드 하는 아티클 모델을 생성할 때 이미지 url이 있으면 추가해서 업로드해야 이후 아티클 목록에서 이미지를 제대로 표시할 수 있기 때문에 url 을 받아 넘겨 처리하는 람다를 하나 받아서 아티클 업로드에 사용하도록 해주었습니다.

이후 채팅목록, 채팅방 등도 동일한 매커니즘으로 리사이클러뷰를 사용해서 구현해주면 앱을 완성시킬 수 있습니다. 전부 완성된 전체 코드와 전체 프로젝트의 경우 저의 깃허브 저장소를 통해 확인할 수 있습니다.

본 글에는 파이어베이스 초기 설정, 서비스 json 추가 등의 설명은 되어있지 않아 실제 사용시 설정이 필요할 수 있습니다. 설정 포스팅은 이전 포스팅을 참고해주세요.

참고로 파이어베이스 스토리지의 경우 로그인 하지 않아도 권한 없이 파일을 올리도록 하고 싶으면 아래 코드를 적용해주시면 됩니다.

rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    match /{allPaths=**} {
      allow read, write : if true;
      //allow read, write: if request.auth != null;
    }
  }
}

2021.06.23 - [Android] - [Android] 커스텀 뷰 에서 엑티비티 종료 시키기 (customView finish)

2021.06.22 - [Android] - [Android] 코루틴으로 url 이미지 불러오기 (String 👉 Bitmap)

2021.06.22 - [Android/Kotlin] - [Android] 코틀린(Kotlin) 코루틴(Coroutine) 한 번에 끝내기

 

댓글
최근에 올라온 글
최근에 달린 댓글
네이버 이웃추가
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함