@ 과제 링크
https://teamsparta.notion.site/Android-de05cc5f0d054de9964f8ad1f116b784
2. 설계
1) 데이터
<1>Item: 인텐트로 주고받기 위해서 Pacelable을 상속받는 Item class를 만든다
<2>Items: 데이터들을 저장해둘 오브젝트 클래스를 생성해서 싱글톤 패턴으로 데이터를 관리한다.
2)리사이클러뷰: 아이템들을 목록화 시키기 위함
<1>RecyclerviewFragment: 리사이클러뷰를 넣을 프래그먼트. 클릭에 대한 함수를 오버라이드하고 어댑터와 리사이클러뷰를 연결시킴
<2>CustomAdapter: 리사이클러뷰의 아이템을 관리하는 어댑터로 데이터와 뷰를 바인딩 시킨다.
3)메인화면 :HomeActivity
<1>알림: 알림에 대한 권한을 체크하여 얻도록 하고 알림 채널을 생성, 알림을 보내는 역할을 함.
<2>프래그먼트: 리사이클러뷰프래그먼트를 뷰에 결합시켜 보여줌
4)상세 화면:DetailActivity
-화면 표시: 리사이클러뷰에서 클릭한 아이템에 대한 정보를 인텐트로 받아서 화면에 표시
3. 구현
1)Item
@Parcelize
data class Item(
val num: Int,
val image: String,
val title: String,
val introduce: String,
val seller: String,
val location: String,
val price: String,
var like: Int,
val chat: Int,
var checked: Boolean
) : Parcelable
-필요한 아이템의 정보를 Parcelable을 상속받는 데이터 클래스로 묶음
2)Items
object Items {
val items = ArrayList<Item>()
val likes = intArrayOf(12, 8, 23, 14, 22, 25, 142, 31, 7, 40)
val chats = intArrayOf(25, 28, 5, 17, 9, 16, 54, 7, 28, 6)
fun inits(context: Context) {
for (i in 1..10) {
val image = "sample${i}"
val titleId = context.resources.getIdentifier(
"item_sample${i}_title",
"string",
context.packageName
)
val title = context.resources.getString(titleId)
val introduceId = context.resources.getIdentifier(
"item_sample${i}_introduce",
"string",
context.packageName
)
val introduce = context.resources.getString(introduceId)
val sellerId = context.resources.getIdentifier(
"item_sample${i}_seller",
"string",
context.packageName
)
val seller = context.resources.getString(sellerId)
val locationID = context.resources.getIdentifier(
"item_sample${i}_location",
"string",
context.packageName
)
val location = context.resources.getString(locationID)
val priceId = context.resources.getIdentifier(
"item_sample${i}_price",
"string",
context.packageName
)
val price = context.resources.getString(priceId)
items += Item(
i,
image,
title,
introduce,
seller,
location,
Convert(price),
likes[i - 1],
chats[i - 1],
false
)
}
}
fun Convert(s: String): String {
val tmp = StringBuilder()
tmp.append("원")
for ((i, v) in s.reversed().withIndex()) {
if (i % 3 == 0 && i != 0) tmp.append(',')
tmp.append(v)
}
return tmp.reversed().toString()
}
}
-string.xml에 저장된 데이터들을 inits메소드를 실행시키면 오브젝트 클래스내에 Item class 리스트 형태로 저장되도록 함
-inits는 앱이 시작되며 해당 데이터들이 처음 사용되는 시점에 실행된다.
-과제 조건에 맞춰 가격 정보를 Convert함수로 가공시킨다.
3)CustomAdapter
package com.example.applemarket
import android.graphics.Bitmap
import android.icu.text.Transliterator.Position
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.example.applemarket.databinding.FragmentRecyclerviewBinding
import com.example.applemarket.databinding.RecyclerviewItemBinding
class CustomAdapter() : RecyclerView.Adapter<CustomAdapter.Holder>() {
interface ItemClick {
fun onClick(view: View, position: Int)
fun onLongClick(view: View, position: Int)
}
var itemClick: ItemClick? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
val binding =
RecyclerviewItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return Holder(binding)
}
override fun onBindViewHolder(holder: Holder, position: Int) {
val context = holder.binding.root.context
holder.binding.root.setOnClickListener {
itemClick?.onClick(it, position)
}
holder.binding.root.setOnLongClickListener {
itemClick?.onLongClick(it, position)
return@setOnLongClickListener (true)
}
val item = Items.items[position]
holder.image.setImageDrawable(
context.getDrawable(
context.resources.getIdentifier(
item.image,
"drawable",
context.packageName
)
)
)
holder.title.text = item.title
holder.location.text = item.location
holder.price.text = item.price
holder.heart.text = item.like.toString()
holder.icHeart.setImageResource(if (item.checked) R.drawable.ic_filledheart else R.drawable.ic_heart)
}
override fun getItemCount(): Int {
return Items.items.size
}
override fun getItemId(position: Int): Long {
return position.toLong()
}
inner class Holder(val binding: RecyclerviewItemBinding) :
RecyclerView.ViewHolder(binding.root) {
val image = binding.itemImage
val title = binding.itemTitle
val location = binding.itemLocation
val price = binding.itemPrice
val heart = binding.itemHeartCount
val icHeart = binding.itemIcHeart
}
}
-Holder는 RecyclerView.ViewHolder를 상속받고 미리 만들어둔 아이템이 정보가 들어간 레이아웃인 fragment_recyclerview에 대한 바인딩클래스를 인자로 받는다.
-클릭은 롱클릭과 일반클릭에 대한 작업을 만들어야 하므로 클릭 인터페이스가 각각에 해당하는 메소드를 포함하도록 만든다. 그리고 나서 해당 인터페이스를 타입으로 하는 프로퍼티 생성
=>클릭에 대한 오버라이드는 리사이클러뷰프래그먼트에서
-onCreateViewHolder에서 Holder를 생성해서 반환.
-onBindViewHolder에서 클릭에 대한 리스너를 만들고 Holder에 데이터를 바인딩 해줌.
4)RecyclerviewFragment
class RecyclerviewFragment : Fragment() {
private var _binding: FragmentRecyclerviewBinding? = null
private val binding get() = _binding!!
private var y = 0
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
_binding = FragmentRecyclerviewBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val adapter = CustomAdapter()
val launcher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
adapter.notifyDataSetChanged()
}
adapter.itemClick = object : CustomAdapter.ItemClick {
override fun onClick(view: View, position: Int) {
val intent = Intent(activity, DetailActivity::class.java)
intent.putExtra("item", Items.items[position])
launcher.launch(intent)
}
override fun onLongClick(view: View, position: Int) {
val ctx = context
if (ctx == null) return
var builder = AlertDialog.Builder(ctx)
builder.setIcon(ctx.getDrawable(R.drawable.ic_chat))
builder.setTitle("상품 삭제")
builder.setMessage("상품을 정말로 삭제하시겠습니까?")
val listener = object : DialogInterface.OnClickListener {
override fun onClick(dialog: DialogInterface?, which: Int) {
when (which) {
DialogInterface.BUTTON_POSITIVE -> run {
Items.items.removeAt(position)
adapter.notifyDataSetChanged()
}
else -> return
}
}
}
builder.setPositiveButton("확인", listener)
builder.setNegativeButton("취소", listener)
builder.show()
}
}
binding.fragmentRecyclerView.apply {
layoutManager = LinearLayoutManager(context)
this.adapter = adapter
addItemDecoration(DividerItemDecoration(context, LinearLayout.VERTICAL))
addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
if (binding.fragmentRecyclerView.canScrollVertically(-1)) {
if (binding.scrollbtn.visibility == INVISIBLE) {
binding.scrollbtn.visibility = VISIBLE
val fadeIn = ObjectAnimator.ofFloat(binding.scrollbtn, "alpha", 0f, 1f)
fadeIn.duration = 400
fadeIn.start()
}
} else {
val fadeOut = ObjectAnimator.ofFloat(binding.scrollbtn, "alpha", 1f, 0f)
fadeOut.duration = 200
fadeOut.start()
Thread {
Thread.sleep(200)
binding.scrollbtn.visibility = INVISIBLE
}.start()
}
}
})
}
binding.scrollbtn.setOnClickListener {
binding.fragmentRecyclerView.smoothScrollToPosition(0)
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
-뷰바인딩을 사용
-리사이클러뷰 구현
-어댑터 설정
[1]클릭에 대한 오버라이드
-onClick: 클릭 후 해당 페이지에서 관심목록을 누르면 돌아왔을때 관심목록 개수와 아이콘이 변경되어야 하므로 registerForActivityResult를 이용하여 어댑터에 notifyDataSetChanged() 메소드를 실행시켜 데이터 변경을 알게함
-onLongClick: 삭제 여부에 대한 다이얼로그를 띄우고 다이얼로그에서 삭제를 선택하면 해당 아이템을 오브젝트 클래스내에서 데이터가 삭제되도록 하고 이후 마찬가지로 리사이클러뷰가 생신되어야 하므로 notifyDataSetChanged() 메소드를 실행시킨다.
[2]어댑터 연결과 스크롤 설정
-어댑터를 연결
-addItemDecoration으로 회색 구분선 추가
-addOnScrollListener: 스크롤시 스크롤이 맨위에 있는 경우가 아니면 플로팅버튼이 나타나도록 함.
=>방법은 canScrollVertically(-1)로 위로 더 스크롤이 가능한지 체크해서 맨위인지 여부를 확인.
-fade in/out 효과: ObjectAnimator을 이용하여 구현. 이때 사라지거나 생겨나는게 visibility 설정과의 순서도 중요
[1]생겨날때는 먼저visibility=VISIBLE을 설정 후 fadeIn
[2]사라질때는 fadeOut실행시킨뒤 Thread로 fadeOut이 끝나기를 기다렸다가 visibility=INVISIBLE로 설정
-스크롤버튼: 스크롤버튼 클릭시 맨 위로 가도록 함=>smoothScrollToPosition(0)
5)HomeActivity
class HomeActivity : AppCompatActivity() {
private lateinit var binding: ActivityHomeBinding
private val myNotificationID = 1
private val channelID = "default"
private val callback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
var builder = AlertDialog.Builder(this@HomeActivity)
builder.setTitle("종료")
builder.setIcon(getDrawable(R.drawable.ic_chat))
builder.setMessage("정말 종료하시겠습니까?")
val listener = object : DialogInterface.OnClickListener {
override fun onClick(dialog: DialogInterface?, which: Int) {
when (which) {
DialogInterface.BUTTON_POSITIVE -> finish()
else -> return
}
}
}
builder.setPositiveButton("확인", listener)
builder.setNegativeButton("취소", listener)
builder.show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
binding = ActivityHomeBinding.inflate(layoutInflater)
setContentView(binding.root)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
this.onBackPressedDispatcher.addCallback(this, callback)
Items.inits(this)
supportFragmentManager.beginTransaction()
.replace(binding.homeFrameLayout.id, RecyclerviewFragment()).commit()
getPermission()
createNotificationChannel()
binding.homeImageViewAlarm.setOnClickListener {
showNotification()
}
}
fun getPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (!NotificationManagerCompat.from(this).areNotificationsEnabled()) {
// 알림 권한이 없다면, 사용자에게 권한 요청
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
}
startActivity(intent)
}
}
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // Android 8.0
val channel = NotificationChannel(
channelID, "default channel",
NotificationManager.IMPORTANCE_DEFAULT
)
channel.description = "description text of this channel."
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
private fun showNotification() {
val builder = NotificationCompat.Builder(this, channelID)
.setSmallIcon(R.drawable.ic_circle)
.setContentTitle("키워드 알림")
.setContentText("설정한 키워드에 대한 알림이 도착했습니다!!")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
if (ActivityCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
getPermission()
return
}
NotificationManagerCompat.from(this).notify(myNotificationID, builder.build())
}
}
-뒤로 가기시 설정
[1]onBackPressedCallback의 handleOnBackPressed를 다이얼로그를 띄우도록 오버라이드
[2]콜백 메소드를 추가
this.onBackPressedDispatcher.addCallback(this, callback)
-오브젝트 클래스 초기화를 위해 Items.inits(this) 실행
-FrameLayout에 RecyclerviewFragment결합
-getPermission(): SDK버전을 체크햇 알림권한이 필요한 버전이면 알림권한 여부를 체크후 사용자에게 권한 요청
-createNotificationChannel(): SDK버전을 체크 후 필요한 경우 알림채널을 생성
-showNotification(): 알림을 만들고 권한 체크후 권한이 없으면 권한 요청을 하고 아니면 알림을 보내준다.
6)DetailActivity
class DetailActivity : AppCompatActivity() {
private lateinit var binding: ActivityDetailBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityDetailBinding.inflate(layoutInflater)
enableEdgeToEdge()
setContentView(binding.root)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
val item = intent.getParcelableExtra<Item>("item")!!
binding.detailBack.setOnClickListener {
finish()
}
with(binding) {
detailImage.setImageDrawable(
getDrawable(
resources.getIdentifier(
item.image,
"drawable",
this@DetailActivity.packageName
)
)
)
detailId.text = item.seller
detailLocation.text = item.location
detailTitle.text = item.title
detailIntroduce.text = item.introduce
detailPrice.text = item.price
detailHeart.setImageResource(if (item.checked) R.drawable.ic_filledheart else R.drawable.ic_heart)
}
binding.detailHeart.setOnClickListener {
item.checked = !item.checked
Items.items[item.num - 1].apply {
checked = item.checked
if (item.checked) this.like++ else this.like--
}
binding.detailHeart.setImageResource(if (item.checked) R.drawable.ic_filledheart else R.drawable.ic_heart)
if (item.checked) run {
val snackbar = Snackbar.make(binding.root, "관심 목록에 추가되었습니다.", Snackbar.LENGTH_SHORT)
val snackbarLayout = snackbar.view.layoutParams as FrameLayout.LayoutParams
snackbarLayout.width = ConvertDPtoPX(this, 350)
snackbarLayout.gravity = Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM
snackbar.show()
}
}
}
fun ConvertDPtoPX(context: Context, dp: Int): Int {
val density = context.resources.displayMetrics.density
return Math.round(dp.toFloat() * density)
}
}
-액티비티를 실행하면서 받아온 데이터로 화면을 구성해줌
-관심목록 클릭시 현재 데이터와 오브젝트 클래스내의 데이터를 갱신해주면서 화면도 갱신
-이때 스낵바를 띄워주는데 스낵바가 적정 위치와 크기를 가지도록 snackbar.view.layoutParams 로 gravity와 width를 설정.
=>여기서 width가 받는 값은 px이므로 DP값을 px로 환산하는 함수를 만들어 이용해준다.
4. 몰랐던 부분 알게된것.
1)플로팅 버튼의 selector 사용법
-아이콘(src)의 경우는 drawable의 selector을 사용
-배경색(backgroundtint)의 경우 color폴더를 만들어 안에 selector을 만들어 사용
2)플로팅 버튼의 모양 설정
-둥근 모양의 경우: shapeAppearance를 사용
-사각 모양의 경우: shapeAppearanceOverlay를 사용
-커스텀: style에서 만들고 shapeAppearanceOverlay에 적용
3)fade효과
-ObjectAnimator사용
-duration으로 진행 시간 설정 가능
4)말줄임표 효과
-속성에 ellipsize = true 로 넣고 maxLine 설정
5)String으로 아이디값 가져오기
-getIdentifier(이름,타입,context.packageName)
6)setOnLongClickListener
-반환값으로 boolean을 주어야 한다(true)
7)return annotation
-this에 annotation을 붙이듯이 return도 어느부분에 대한 반환인지 annotation을 붙여 명확히 한다.
8)onBackpresedDispatcher
-뒤로가기에 대한 콜백함수로 프래그먼트에서도 사용 가능하다.
<1>프로퍼티에 callback 생성
private val callback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
//뒤로가기 버튼 클릭시 행한 작업 넣기
}
}
<2>onCreate() 에서 콜백 추가
this.onBackPressedDispatcher.addCallback(this, callback)
9)dp를 px로 변환
fun ConvertDPtoPX(context: Context, dp: Int): Int {
val density = context.resources.displayMetrics.density
return Math.round(dp.toFloat() * density)
}
10)스낵바 사용법 및 디자인 변경
val snackbar = Snackbar.make(binding.root, "관심 목록에 추가되었습니다.", Snackbar.LENGTH_SHORT)
val snackbarLayout = snackbar.view.layoutParams as FrameLayout.LayoutParams
snackbarLayout.width = ConvertDPtoPX(this, 350)
snackbarLayout.gravity = Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM
snackbar.show()
'코틀린 개인 과제' 카테고리의 다른 글
뉴스 리더 앱 (0) | 2024.07.18 |
---|---|
연락처 리스트앱 구현 (0) | 2024.07.18 |
회원가입 MVVM 과제 (0) | 2024.07.14 |
챌린지반 3주차 첫번째 과제: 디자인 패턴 구현 (0) | 2024.07.09 |
로그인 앱 제작-2 (0) | 2024.06.25 |