코틀린 개인 과제

AppleMarket 구현 과제

songyooho 2024. 7. 15. 12:51

@ 과제 링크

https://teamsparta.notion.site/Android-de05cc5f0d054de9964f8ad1f116b784

 

Android 앱개발 숙련 개인 과제 | Notion

Goal : 사과마켓 앱 만들기 (feat. 당근마켓)

teamsparta.notion.site

 

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