[Android / Kotlin] AppleMarket (3)

Subeen·2024년 1월 8일
0

Android

목록 보기
36/73

과제 목표

  • 스크롤 상단 이동
    [v] 스크롤을 최상단으로 이동시키는 플로팅 버튼 기능 추가
    [v] 플로팅 버튼은 스크롤을 아래로 내릴 때 나타나며, 스크롤이 최상단일때 사라집니다.
    [v] 플로팅 버튼을 누르면 스크롤을 최상단으로 이동시킵니다.
    [v] 플로팅 버튼은 나타나고 사라질때 fade 효과가 있습니다.
    [v] 플로팅 버튼을 클릭하면(pressed) 아이콘 색이 변경됩니다.
  • 상품 삭제하기
    [v] 상품을 롱클릭 했을때 삭제 여부를 묻는 다이얼로그를 띄우고
    [v] 확인을 선택시 해당 항목을 삭제하고 리스트를 업데이트한다.
    [v] 해당 상품이 삭제되었는지 확인!!

RecyclerView 스크롤 상단 이동

결과 화면

activity_main.xml

플로팅 버튼을 사용하기 위해 activity_main.xml에 FloatingActionButton을 선언해준다.

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

    <androidx.appcompat.widget.Toolbar
        android:id="@+id/toolBar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <Spinner
            android:id="@+id/spinnerLocation"
            android:layout_width="wrap_content"
            android:layout_height="32dp" />

        <ImageView
            android:id="@+id/ivNotification"
            android:layout_width="32dp"
            android:layout_height="32dp"
            android:layout_gravity="end"
            android:layout_marginEnd="16dp"
            android:background="@drawable/selecter_main_notice_32dp" />

    </androidx.appcompat.widget.Toolbar>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/productRecyclerView"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/toolBar" />

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/floatingButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="32dp"
        android:src="@drawable/img_align_top"
        app:backgroundTint="@color/selector_floating_button"
        app:fabCustomSize="56dp"
        app:fabSize="normal"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:maxImageSize="32dp" />

</androidx.constraintlayout.widget.ConstraintLayout>

selector_floating_button.xml

플로팅 버튼을 클릭했을 때 버튼의 색상이 변경되도록 하기 위해 res/color 위치에 selector 파일을 만들어준다.

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:color="@color/blue" android:state_pressed="true" />
    <item android:color="@color/white" android:state_enabled="true" />
</selector>

MainActiivty.kt

// 플로팅 버튼 클릭 했을 때 RecyclerView의 최상단으로 이동 
binding.floatingButton.setOnClickListener {
	binding.productRecyclerView.smoothScrollToPosition(0) // 최상단 이동
}
// 플로팅 버튼은 스크롤을 아래로 내릴 때 나타나며, 스크롤이 최상단일 때 사라짐
// 나타나고 사라질 때 fade 효과
private fun createScrollListener(): RecyclerView.OnScrollListener {
	return object : RecyclerView.OnScrollListener() {
		override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
			super.onScrolled(recyclerView, dx, dy)
			with(binding.floatingButton) {
				if (!binding.productRecyclerView.canScrollVertically(-1)) { // 최상단일 때 
					animate().alpha(0f).duration = 200
					visibility = GONE
				} else {
					visibility = VISIBLE
					animate().alpha(1f).duration = 200
				}
			}
		}
	}
}

class MainActivity : AppCompatActivity() {
    companion object {
        const val EXTRA_PRODUCT_ENTITY = "extra_product_entity"
        const val NOTIFICATION_CHANNEL_ID = "one-channel"
        const val NOTIFICATION_ID = 11
    }

    private val binding: ActivityMainBinding by lazy {
        ActivityMainBinding.inflate(layoutInflater)
    }

    private val onBackPressedCallback = object : OnBackPressedCallback(true) {
        override fun handleOnBackPressed() {
            showBackPressedAlertDialog()
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)

        initView()
        this.onBackPressedDispatcher.addCallback(this, onBackPressedCallback)

    }

    private fun initView() {
        setSpinnerLocation()
        initRecyclerView()
        binding.ivNotification.setOnClickListener {
            showNotification()
        }
        binding.floatingButton.setOnClickListener {
            binding.productRecyclerView.smoothScrollToPosition(0) // 최상단 이동
        }
    }

    private fun initRecyclerView() {
        val items = loadList()
        val adapter = ProductAdapter(items)

        adapter.itemClick = object : ProductAdapter.ItemClick {
            override fun onItemClick(view: View, position: Int) {
                val intent = Intent(this@MainActivity, DetailActivity::class.java)
                val data = ProductEntity(
                    items[position].id,
                    items[position].resId,
                    items[position].name,
                    items[position].explain,
                    items[position].seller,
                    items[position].price,
                    items[position].location,
                    items[position].like,
                    items[position].chat
                )
                intent.putExtra(EXTRA_PRODUCT_ENTITY, data)
                startActivity(intent)
            }

            override fun onItemLongLick(view: View, position: Int) {
                showAlertDialog(
                    getString(R.string.dialog_remove_title),
                    getString(R.string.dialog_remove_message),
                    R.drawable.img_main_chat_16dp,
                    getString(R.string.dialog_button_positive),
                    {
                        if (removeProductItem(position)) { // 아이템 삭제가 완료 됐을 때
                            adapter.notifyItemRangeRemoved(position, items.size)
                        }
                    },
                    getString(R.string.dialog_button_negative)
                )
            }
        }

        binding.productRecyclerView.run {
            layoutManager = LinearLayoutManager(this@MainActivity)
            addItemDecoration(
                DividerItemDecoration(
                    this@MainActivity,
                    LinearLayoutManager.VERTICAL
                )
            )
            this.adapter = adapter

            addOnScrollListener(createScrollListener())
        }
    }

    private fun createScrollListener(): RecyclerView.OnScrollListener {
        return object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)
                with(binding.floatingButton) {
                    if (!binding.productRecyclerView.canScrollVertically(-1)) {
                        animate().alpha(0f).duration = 200
                        visibility = GONE
                    } else {
                        visibility = VISIBLE
                        animate().alpha(1f).duration = 200
                    }
                }
            }
        }
    }

    private fun showBackPressedAlertDialog() {
        showAlertDialog(
            getString(R.string.dialog_title),
            getString(R.string.dialog_message),
            R.drawable.img_main_chat_16dp,
            getString(R.string.dialog_button_positive),
            { finish() },
            getString(R.string.dialog_button_negative)
        )
    }

    private fun showAlertDialog(
        title: String,
        message: String,
        iconResId: Int,
        positiveButtonText: String,
        positiveAction: () -> Unit,
        negativeButtonText: String,
        negativeAction: (() -> Unit)? = null
    ) {
        AlertDialog.Builder(this@MainActivity).apply {
            setTitle(title)
            setMessage(message)
            setIcon(iconResId)
            setPositiveButton(positiveButtonText) { _, _ -> positiveAction.invoke() }
            setNegativeButton(negativeButtonText, null)
            negativeAction?.let { setNegativeButton(negativeButtonText) { _, _ -> it.invoke() } }
        }.show()
    }

    private fun setSpinnerLocation() {
        binding.spinnerLocation.adapter = ArrayAdapter(
            this@MainActivity,
            android.R.layout.simple_spinner_dropdown_item,
            listOf(
                getString(R.string.location_0)
            )
        )
    }

    private fun showNotification() {
        createNotificationChannel()

        val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID).apply {
            setSmallIcon(R.mipmap.ic_launcher)
            setWhen(System.currentTimeMillis())
            setContentTitle(getString(R.string.notification_title))
            setContentText(getString(R.string.notification_message))
        }

        val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
        manager.notify(NOTIFICATION_ID, builder.build())
    }

    private fun createNotificationChannel() {
        val channel = NotificationChannel(
            NOTIFICATION_CHANNEL_ID,
            getString(R.string.notification_channel_name),
            NotificationManager.IMPORTANCE_DEFAULT
        ).apply {
            description = getString(R.string.notification_channel_description)
            enableVibration(true)
        }

        val uri: Uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
        val audioAttributes = AudioAttributes.Builder()
            .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
            .setUsage(AudioAttributes.USAGE_ALARM)
            .build()
        channel.setSound(uri, audioAttributes)

        val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
        manager.createNotificationChannel(channel)
    }
}

RecyclerView 아이템 삭제

결과 화면

ProductAdapter.kt

ItemClick 인터페이스에 아이템을 롱클릭 했을 때의 함수를 추가했다.

class ProductAdapter(private val items: MutableList<ProductEntity>) :
    RecyclerView.Adapter<ProductAdapter.Holder>() {

    interface ItemClick {
        fun onItemClick(view: View, position: Int)
        fun onItemLongLick(view: View, position: Int) // 롱클릭 함수 추가 
    }

    var itemClick: ItemClick? = null

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProductAdapter.Holder {
        val binding =
            ItemProductRecyclerBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return Holder(binding)
    }

    override fun onBindViewHolder(holder: ProductAdapter.Holder, position: Int) {
        holder.itemView.setOnClickListener {
            itemClick?.onItemClick(it, position)
        }
        holder.itemView.setOnLongClickListener { // 아이템 롱클릭 했을 때 
            itemClick?.onItemLongLick(it, position)
            return@setOnLongClickListener (false) // 직후 click event 를 받기 위해 false 반환
        }
        items[position].resId?.let { holder.productImageView.setImageResource(it) }
        holder.productImageView.clipToOutline = true

        holder.productName.text = items[position].name
        holder.productLocation.text = items[position].location
        val price = "${items[position].price?.let { it.decimalFormat() }}원"
        holder.productPrice.text = price

        val chatCount = "${items[position].chat}"
        val likeCount = "${items[position].like}"
        holder.productChatCount.text = chatCount
        holder.productLikeCount.text = likeCount
    }

    override fun getItemCount(): Int = items.size

    inner class Holder(binding: ItemProductRecyclerBinding) :
        RecyclerView.ViewHolder(binding.root) {
        val productImageView = binding.ivProduct
        val productName = binding.tvProductName
        val productLocation = binding.tvProductLocation
        val productPrice = binding.tvProductPrice
        val productChatCount = binding.tvChatCount
        val productLikeCount = binding.tvLikeCount

    }
}

MainActivity.kt

아이템을 롱클릭 했을 때 removeProductItem 함수에서 아이템이 정상적으로 삭제 되어 true를 반환할 때만 RecyclerView를 업데이트 해준다.

    private fun initRecyclerView() {
        val items = loadList()
        val adapter = ProductAdapter(items)

        adapter.itemClick = object : ProductAdapter.ItemClick {
            override fun onItemClick(view: View, position: Int) {
                val intent = Intent(this@MainActivity, DetailActivity::class.java)
                val data = ProductEntity(
                    items[position].id,
                    items[position].resId,
                    items[position].name,
                    items[position].explain,
                    items[position].seller,
                    items[position].price,
                    items[position].location,
                    items[position].like,
                    items[position].chat
                )
                intent.putExtra(EXTRA_PRODUCT_ENTITY, data)
                startActivity(intent)
            }

            override fun onItemLongLick(view: View, position: Int) { // 아이템 롱클릭 했을 때 
                showAlertDialog(
                    getString(R.string.dialog_remove_title),
                    getString(R.string.dialog_remove_message),
                    R.drawable.img_main_chat_16dp,
                    getString(R.string.dialog_button_positive),
                    {
                        if (removeProductItem(position)) { // 아이템 삭제가 완료 됐을 때
                            adapter.notifyItemRangeRemoved(position, items.size) // RecyclerView 업데이트 
                        }
                    },
                    getString(R.string.dialog_button_negative)
                )
            }
        }

        binding.productRecyclerView.run {
            layoutManager = LinearLayoutManager(this@MainActivity)
            addItemDecoration(
                DividerItemDecoration(
                    this@MainActivity,
                    LinearLayoutManager.VERTICAL
                )
            )
            this.adapter = adapter

            addOnScrollListener(createScrollListener())
        }
    }
    
        private fun showAlertDialog( // Dialog 생성 
        title: String,
        message: String,
        iconResId: Int,
        positiveButtonText: String,
        positiveAction: () -> Unit,
        negativeButtonText: String,
        negativeAction: (() -> Unit)? = null
    ) {
        AlertDialog.Builder(this@MainActivity).apply {
            setTitle(title)
            setMessage(message)
            setIcon(iconResId)
            setPositiveButton(positiveButtonText) { _, _ -> positiveAction.invoke() }
            setNegativeButton(negativeButtonText, null)
            negativeAction?.let { setNegativeButton(negativeButtonText) { _, _ -> it.invoke() } }
        }.show()
    }

ProductObject.kt

리스트에서 아이템을 삭제 하기 위한 함수를 추가하였으며 예외 처리를 하여 IndexOutOfBoundsException가 발생할 때는 false를, 아이템이 정상적으로 삭제 되었을 때는 true를 반환한다.

object ProductManager {
    private var items: MutableList<ProductEntity> = arrayListOf()

    fun Context.loadList(): MutableList<ProductEntity> {
        val assetManager = assets
        val inputStream = assetManager.open("dummy_data.tsv")
        val bufferedReader = BufferedReader(InputStreamReader(inputStream))
        bufferedReader.forEachLine {
            val tokens = it.split("\t")
            val resource = resources.getIdentifier(tokens[1], "drawable", packageName)
            val post = ProductEntity(
                tokens[0].toInt(),
                resource,
                tokens[2],
                tokens[3].replace("\\n", "\n").replace(" + ", "").replace("\"", ""),
                tokens[4],
                tokens[5].toInt(),
                tokens[6],
                tokens[7].toInt(),
                tokens[8].toInt()
            )
            items.add(post)
        }
        return items
    }


    fun removeProductItem(position: Int): Boolean = // 리스트에서 아이템 삭제 
        try {
            items.removeAt(position)
            true
        } catch (e: IndexOutOfBoundsException) {
            false
        }
        
}
profile
개발 공부 기록 🌱

0개의 댓글