과제 목표
- 스크롤 상단 이동
[v] 스크롤을 최상단으로 이동시키는 플로팅 버튼 기능 추가
[v] 플로팅 버튼은 스크롤을 아래로 내릴 때 나타나며, 스크롤이 최상단일때 사라집니다.
[v] 플로팅 버튼을 누르면 스크롤을 최상단으로 이동시킵니다.
[v] 플로팅 버튼은 나타나고 사라질때 fade 효과가 있습니다.
[v] 플로팅 버튼을 클릭하면(pressed) 아이콘 색이 변경됩니다.- 상품 삭제하기
[v] 상품을 롱클릭 했을때 삭제 여부를 묻는 다이얼로그를 띄우고
[v] 확인을 선택시 해당 항목을 삭제하고 리스트를 업데이트한다.
[v] 해당 상품이 삭제되었는지 확인!!
플로팅 버튼을 사용하기 위해 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>
플로팅 버튼을 클릭했을 때 버튼의 색상이 변경되도록 하기 위해
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>
// 플로팅 버튼 클릭 했을 때 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)
}
}
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
}
}
아이템을 롱클릭 했을 때
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()
}
리스트에서 아이템을 삭제 하기 위한 함수를 추가하였으며 예외 처리를 하여
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
}
}