728x90

5일 차 swipe-to-action  오른쪽으로 밀었을 때 아이템 삭제 기능 추가

ContactListAdapter 

const val TYPE_HEADER = 0
const val TYPE_CONTENT_LIST = 1
const val TYPE_CONTENT_GRID = 2


class ContactListAdapter(private val layoutManager: GridLayoutManager) :
    ListAdapter<ContactItems, RecyclerView.ViewHolder>(diffUtil) {
    var itemClick: ItemClick? = null
    var favoriteClick: FavoriteClick? = null

    companion object {
        val diffUtil = object : DiffUtil.ItemCallback<ContactItems>() {
            override fun areItemsTheSame(oldItem: ContactItems, newItem: ContactItems): Boolean {
                return oldItem.ItemID == newItem.ItemID
            }

            override fun areContentsTheSame(oldItem: ContactItems, newItem: ContactItems): Boolean {
                return oldItem == newItem
            }
        }
    }

    interface ItemClick {
        fun onClick(item: ContactItems)
    }

    interface FavoriteClick {
        fun onFavoriteClick(item: ContactItems, position: Int)
    }

    override fun getItemViewType(position: Int): Int {
        val item = getItem(position)
        val span = layoutManager.spanCount

        if (item is ContactItems.Header) {
            return TYPE_HEADER
        }

        if (item is ContactItems.Contents && span == 1) {
            return TYPE_CONTENT_LIST
        }

        if (item is ContactItems.Contents && span > 1) {
            return TYPE_CONTENT_GRID
        }
        throw IllegalArgumentException("Invaild span count")
    }

    override fun onCreateViewHolder(
        parent: ViewGroup,
        viewType: Int
    ): RecyclerView.ViewHolder {
        val inflater = LayoutInflater.from(parent.context)

        if (viewType == TYPE_HEADER) {
            return HeaderViewHolder(ItemHeaderRecyclerViewBinding.inflate(inflater, parent, false))
        }

        if (viewType == TYPE_CONTENT_LIST) {
            return ContentsViewHolder(
                ItemContactRecyclerViewBinding.inflate(
                    inflater,
                    parent,
                    false
                )
            )
        }

        if (viewType == TYPE_CONTENT_GRID) {
            return ContentGridItemViewHolder(
                ItemContactRecyclerViewGridBinding.inflate(
                    inflater,
                    parent,
                    false
                )
            )
        }
        throw IllegalArgumentException("Invalid view type")
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        val item = getItem(position)

        if (item is ContactItems.Header) {
            with(holder as HeaderViewHolder) {
                location.text = item.location
            }
        }

        if (item is ContactItems.Contents) {
            with(holder as ContactItemViewHolder) {
                name.text = item.itemName
                ivThumbnail.setThumbnailImage(item.thumbnailImage)
                favoriteIcon.checkFavorite(item)
                itemView.setOnClickListener { itemClick?.onClick(item) }
                favoriteButton.setOnClickListener { favoriteClick?.onFavoriteClick(item, position) }
            }
        }
    }

    private fun ImageView.setThumbnailImage(image: Int?) {
        load(image ?: R.drawable.main_symbol)
    }

    private fun ImageView.checkFavorite(item: ContactItems.Contents) {
        load(if (item.favorite) R.drawable.favorite_big_on else R.drawable.favorite_big_off)
    }

    fun getPosition(position: Int): ContactItems {
        return ContactItemManager.sortAllWithHeader()[position]
    }
}

 

SwipeHelperCallback

class SwipeHelperCallback(
    private val context: Context,
    private var adapter: ContactListAdapter,
    private val recyclerView: RecyclerView
) :
    ItemTouchHelper.Callback() {
    override fun getMovementFlags(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder
    ): Int {
        if (viewHolder.itemViewType == TYPE_HEADER) {
            return makeMovementFlags(0, 0)
        }
        return makeMovementFlags(0, LEFT or RIGHT)
    }

    override fun onMove(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder,
        target: RecyclerView.ViewHolder
    ): Boolean {
        return false
    }

    override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
        val position = viewHolder.adapterPosition
        val item = adapter.getPosition(position) as ContactItems.Contents

        //오른쪽 스와이프 시 전화연결 //왼쪽 스와이프 시 아이템 삭제
        if (direction == ItemTouchHelper.RIGHT) callConnect(item) else removeContact(item)
        adapter.notifyDataSetChanged()
    }
	//스와이프 속도 
    override fun getSwipeEscapeVelocity(defaultValue: Float): Float {
        return defaultValue * 20
    }

    private fun callConnect(item: ContactItems.Contents) {
        item.let {
            val phoneNumber = it.phoneNumber
            Intent(Intent.ACTION_DIAL).apply {
                data = Uri.parse("tel:$phoneNumber")
                context.startActivity(this)
            }
        }
    }

    private fun removeContact(item: ContactItems.Contents) {
        val logoImage = ImageView(context).apply {
            scaleType = ImageView.ScaleType.CENTER_CROP
            val width = LayoutParams.MATCH_PARENT
            val height = 500
            layoutParams = LinearLayout.LayoutParams(width, height).apply {
                topMargin = 100
            }
            adjustViewBounds = true
            item.thumbnailImage?.let { setImageResource(it) }

        }
        val contactText = TextView(context).apply {
            textSize = 30f
            gravity = Gravity.CENTER
            item.phoneNumber.let { text = it }
        }
        val messageText = TextView(context).apply {
            textSize = 20f
            gravity = Gravity.CENTER
            layoutParams =
                LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
                    .apply {
                        topMargin = 100
                        bottomMargin = 50
                    }
            text = "연락처를 삭제 하시겠습니까?"
        }
        val layout = LinearLayout(context).apply {
            orientation = LinearLayout.VERTICAL
            addView(logoImage)
            addView(contactText)
            addView(messageText)
        }

        AlertDialog.Builder(context).apply {
            setView(layout)
            setPositiveButton("확인") { _, _ ->
                item.let {
                    ContactItemManager.contactItems.remove(item)
                    adapter.submitList(ContactItemManager.sortAllWithHeader())
                }
            }
            setNegativeButton("취소") { _, _ ->
                adapter.notifyDataSetChanged()
            }
            show()
        }
    }
}

 

해당 adapter 에 notify를 해줘야되는데 계속 notifyData~만 사용해서 갱신이 안됐었음 너무 오랜시간 삽질함 

팀 프로젝트를 하면서 가장 어려운건 역시 남이 짠 코드를 보면서 이어서 코드를 작성 할 때 어려움이 있음

내가 실력이 많이 부족한 것을 느낌 

 

 

//아이템 삭제시 dialog는 시간관계상 AlertDialog로 구현 

728x90

3일 차에 아이템이 삭제되지 기능을 스와이프로 전화를 거는 기능으로 수정해서 구현을 함 

swipe-to-action

SwipeHelperCallback 

class SwipeHelperCallback(private val context: Context, private val adapter: ContactListAdapter) :
    ItemTouchHelper.Callback() {
    override fun getMovementFlags(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder
    ): Int {
        return makeMovementFlags(0, RIGHT) //아래,위는 사용x 오른쪽만 스와이프 되게 
    }
	
    override fun onMove(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder,
        target: RecyclerView.ViewHolder
    ): Boolean {
        return false
    }
	//스와이프 되면 onSwiped가 호출됨 
    override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
        val position = viewHolder.adapterPosition
        val item = adapter.getPosition(position) as? ContactItems.Contents
		
        item?.let {
            val phoneNumber = it.phoneNumber
            Intent(Intent.ACTION_DIAL).apply {
                data = Uri.parse("tel:$phoneNumber")
                context.startActivity(this)
            }
        }

    }
	//스와이프 거리 //반만 스와이프해도 onSwiped 되게 설정 
    override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
        return 0.5f
    }

}

ContactListAdapter 의 getPosition

fun getPosition(position: Int): ContactItems {
        return ContactItemManager.sortWithHeader()[position]
    }

SwipeHelperCallback의 생성자 private val context: Context, private val adapter: ContactListAdapter는 context와 adapter를 사용하기 위해 만듦  

 

//리사이클러뷰에 적용

val swipeHelperCallback = SwipeHelperCallback(context,listAdapter)
val itemTouchHelper = ItemTouchHelper(swipeHelperCallback)//ItemTouchHelper에 SwipeHelperCallback 연결 
itemTouchHelper.attachToRecyclerView(binding.recyclerViewContact) //attchToRecyclerView로 recyclerView연결

 

728x90

Swipe To Action 을 구현해보기 위해 밀어서 아이템 삭제 기능을 넣으려고 했는데 버그가 생겨서 하루 이상을 소비 했지만 해결 하지 못한 상태.. 마감기간이 얼마 남지 않아서 기간내에 해결이 안되면 나중에 혼자라도 해결해 봐야겠다. 

728x90

다이얼로그에서 정보를 받아 리사이클러뷰 아이템을 추가하는 기능을 만듦 

 

 

DialogFragment 

class DialogFragment : DialogFragment() {
    private lateinit var binding: FragmentDialogBinding
    private var contactDataListener: ContactDataListener? = null
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = FragmentDialogBinding.inflate(inflater)

        return binding.root
    }

    override fun onResume() {
        //디바이스 가로 사이즈의 90% 설정
        val windowManager =
            requireContext().getSystemService(Context.WINDOW_SERVICE) as WindowManager
        val display = windowManager.defaultDisplay
        val size = Point()
        display.getSize(size)

        val params: ViewGroup.LayoutParams? = dialog?.window?.attributes
        val deviceWidth = size.x
        params?.width = (deviceWidth * 0.9).toInt()
        dialog?.window?.attributes = params as LayoutParams
        super.onResume()
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        with(binding) {
            checkPhoneNumber(etContact, ContactInputLayout)
            check(etName, NameInputLayout)
            check(etAddress, AddressInputLayout)
            spinner.adapter = context?.let { ArrayAdapter.createFromResource(it,R.array.area,android.R.layout.simple_list_item_1) }
            val area = when(spinner.selectedItemPosition){
                0-> SEOUL
                1-> BUSAN
                2-> DAEJEON
                3-> DAEGU
                4-> INCHEON
                5-> GWANGJOO
                6-> ULSAN
                else-> SEOUL
            }

            btnAdd.setOnClickListener {
                val nameEmpty = etName.text!!.isEmpty()
                val addressEmpty = etAddress.text!!.isEmpty()
                val nameBlank = etName.text!!.isBlank()
                val addressBlank = etAddress.text!!.isBlank()
                val contactEmpty = etContact.text!!.isEmpty()
                //빈 값 있는지 체크
                if (nameEmpty || contactEmpty || addressEmpty || !checkNumberLength(
                        etContact
                    ) || nameBlank || addressBlank || NameInputLayout.error != null || ContactInputLayout.error != null || AddressInputLayout.error != null
                ) {
                    if (nameEmpty || nameBlank || NameInputLayout.error != null) NameInputLayout.error =
                        getString(R.string.Name_error_message) else NameInputLayout.error = null
                    if (contactEmpty || !checkNumberLength(etContact) || ContactInputLayout.error != null) ContactInputLayout.error =
                        getString(R.string.Contact_error_message) else ContactInputLayout.error =
                        null
                    if (addressEmpty || addressBlank || AddressInputLayout.error != null) AddressInputLayout.error =
                        getString(R.string.Address_error_message) else AddressInputLayout.error =
                        null


                    return@setOnClickListener
                } else {
                    val item = ContactItems.Contents(
                        etName.text.toString(),
                        etContact.text.toString(),
                        etAddress.text.toString(),
                        area, R.drawable.main_symbol
                    )
                    //리스너 호출
                    contactDataListener?.onContactDataAdded(item)
                    dialog?.dismiss()
                }

            }
            btnCancel.setOnClickListener {
                dialog?.dismiss()
            }


        }
    }

    //전화번호 유효성 체크
    private fun checkPhoneNumber(editText: EditText, layout: TextInputLayout) {
        editText.addTextChangedListener(PhoneNumberFormattingTextWatcher())
        editText.addTextChangedListener(object : TextWatcher {
            override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
            }

            override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {

                if (editText.text.isEmpty() || editText.text.isBlank() || editText.text.contains(" ")) layout.error =
                    getString(R.string.Contact_error_message)
                else if (editText.text[0] != '0' && editText.text[0] != '1') layout.error =
                    getString(R.string.Contact_error_message)
                else if (!editText.text.contains("-")) layout.error =
                    getString(R.string.Contact_error_message)
                else layout.error = null
            }

            override fun afterTextChanged(s: Editable?) {
            }
        })
    }

    //시작 번호가 0일때, 1일때 자리수 체크
    private fun checkNumberLength(editText: EditText): Boolean {
        var state = false
        if (editText.text[0] == '0') {
            if (editText.text.length == 12) state = true
        } else if (editText.text[0] == '1') {
            if (editText.text.length == 9 && editText.text.contains("-")) state = true
        }
        return state
    }

    //병원명, 주소 유효성 체크
    private fun check(editText: EditText, layout: TextInputLayout) {
        editText.addTextChangedListener(object : TextWatcher {
            override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
            }

            override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
                if (editText.text.isEmpty() || editText.text.isBlank() || editText.text.contains(" ")) {
                    layout.error = "${editText.hint}${getString(R.string.check_footer)}"
                } else {
                    layout.error = null
                }
            }

            override fun afterTextChanged(s: Editable?) {
            }
        })

    }
	//리스너 연결
    fun setContactDataListener(listener: ContactDataListener) {
        contactDataListener = listener
    }
}

interface ContactDataListener {
    fun onContactDataAdded(item: ContactItems.Contents)
}

 

ContactFragment

class ContactFragment : Fragment(), ContactDataListener {
    private val binding by lazy { FragmentContactBinding.inflate(layoutInflater) }
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        initRecyclerView()
        binding.btnAdd.setOnClickListener {
            val dialogFragment = DialogFragment()
            dialogFragment.setContactDataListener(this) //리스너 연결 
            dialogFragment.show(requireActivity().supportFragmentManager, "AddContactDialog")
            initToolbarLogo()
        }
    }

    private fun initToolbarLogo() {
        binding.recyclerViewContact.initToolbarLogoWithScroll()
    }

    private fun RecyclerView.initToolbarLogoWithScroll() {
        val fadeInAnim = AnimationUtils.loadAnimation(context, R.anim.fade_in)
        val logo = binding.ivToolbarLogo

        setOnScrollChangeListener { _, _, _, _, _ ->
            if (canScrollVertically(-1) && logo.visibility == View.GONE) {
                logo.apply {
                    startAnimation(fadeInAnim)
                    visibility = View.VISIBLE
                }
            }

            if (!canScrollVertically(-1)) {
                logo.apply {
                    clearAnimation()
                    visibility = View.GONE
                }
            }
        }
    }

    private fun initRecyclerView() {
        with(binding.recyclerViewContact) {
            adapter = ContactListAdapter(ContactItemManager.sortWithHeader())
            layoutManager = LinearLayoutManager(context)
        }
    }

    override fun onContactDataAdded(item: ContactItems.Contents) {
    	//contactItems에 item 추가 
        ContactItemManager.contactItems.add(item)
  		//정렬된 list adapter에 연결 
  		binding.recyclerViewContact.adapter = ContactListAdapter(ContactItemManager.sortWithHeader())
        binding.recyclerViewContact.adapter?.notifyDataSetChanged()
    }

}

구현 화면

 

2일차 트러블슈팅 

아이템을 추가 했을때, 지역별로 추가를 하려고 하는데, item에 add를 하는 방식이라서 맨밑에 추가가 되는 문제가 있었는데, ContactListAdapter의 items에 item을 추가하는 삽질을 계속함.. 데이터를 관리하는 ContactItemManager에있는 contactItems에 추가를 해서 정렬을 하고 아이템을 추가해서 해결함 

728x90

프로젝트 개요

  • 프로젝트 명 : 119해조
    • 팀명에서 착안한 아이디어로 응급실 연락처를 확인하는 연락처 앱.

앱 전체기능

  • 연락처 추가
    • 다이얼로그사용
  • 탭 레이아웃 사용
  • 추가 구현 목록
    • 이벤트 기능 - 5분뒤 알림, 10분뒤 알림

연락처 리스트

  • 연락처 리스트 출력
  • 좋아요 기능
  • 2가지 아이템 뷰타입으로 리사이클러뷰 활용
  • 지역별로 아이템을 정렬해서 Sticky Header를 사용해 카테고리 표시
  • 추가 구현 목록
    • 리사이클러 뷰타입 변경 기능
    • Swipe to Action 기능
    • 핸드폰 연락처 연동

상세 정보

  • 연락처 리스트에서 데이터를 받아 뷰에 출력
  • 전화걸기 버튼
  • 즐겨찾기 버튼
  • 위치정보 표시

마이페이지

  • 사용자 정보 표시
  • 사용자이름
  • 사용자 번호
  • 혈액형
  • 지역
  • 즐겨찾기한 병원

Convention

Branch - 간소화시킨 git flow (HOTFIX, Release X)

  • main
  • develop
  • feature
    • feature/기능-이름

.(마침표) or 공백 사용시 -사용

--사용금지, 마지막에 -사용금지

Commit

  • Title[UI] 디자인 추가[Refactor] 프로덕션 코드 리팩토링
  • [Docs] 문서 수정 (Read me)
  • [Fix] 버그 픽스
  • [Feat] 새로운 기능 추가

ex) git commit -m “ [Feat] : add recycler view”

Commit 절차

  1. feature 브랜치에서 기능 완성
  2. develop 브랜치에서 pull해서 conflict확인
  3. conflict가 발생 했을 시 해결 후 다시 commit
  4. push후 pull request 오픈

1일차 viewPager 와 tab 연결시키기

 

MainActivity 

class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding
    private lateinit var viewPagerAdapter: ViewPagerAdapter
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        //뷰페이저 어뎁터 연결
        viewPagerAdapter = ViewPagerAdapter(this@MainActivity)

        with(binding) {
            //어뎁터 연결
            viewPager.adapter = viewPagerAdapter

            //탭, 뷰페이저 연결
            TabLayoutMediator(tabLayout, viewPager) { tab, position ->

                when (position) {
                    0 -> tab.text = "연락처"
                    else -> tab.text = "내정보"
                }
            }.attach()
        }

    }
}

 

ViewPagerAdapter 

class ViewPagerAdapter(private val mainActivity: MainActivity):FragmentStateAdapter(mainActivity) {
    override fun getItemCount() = 2

    override fun createFragment(position: Int): Fragment {
      return when(position){
           0->{
               MainFragment() //연락처 페이지
           }
           1->{
               MyPageFragment() //마이 페이지
           }
          else->throw IllegalStateException("포지션 에러")
       }
    }
}

 

activity.xml

<?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.viewpager2.widget.ViewPager2
        android:id="@+id/viewPager"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toTopOf="@id/tabLayout"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.google.android.material.tabs.TabLayout
        android:id="@+id/tabLayout"
        android:layout_width="0dp"
        app:tabSelectedTextColor="@color/black"
        app:tabTextAppearance="@style/tab_text"
        app:tabIndicatorColor="#ED1E79"
        app:tabRippleColor="#ED1E79"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"/>

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/container"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

tab의 text는 style로 변경시켜줘야 함 

styles.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>

    <style name="tab_text" parent="TextAppearance.Design.Tab">
        <item name="android:textSize">18sp</item>
        <item name="android:textStyle">bold</item>
    </style>
</resources>

 

 

728x90

Git Branch 전략

Git-Flow 브랜치를 나누는 방법에 대한 분류 중 하나 

gitFlow특징 -> 브랜치를 5종류로 나눔 

  •  
  • master : 제품으로 출시될 수 있는 브랜치
  • develop : 다음 출시 버전을 개발하는 브랜치
  • feature : 기능을 개발하는 브랜치
  • release : 이번 출시 버전을 준비하는 브랜치
  • hotfix : 출시 버전에서 발생한 버그를 수정 하는 브랜치
  1. main, develop 필수 브랜치이지만 나머지 브랜치는 유지 보수를 목적으로 하는 선택적인 브랜치
  2. branch를 merge할 때 항상 -no-ff 옵션을 붙여 branch에 대한 기록이 사라지는 것을 방지하는 것을 원칙으로 한다.  
  3.  

Git-Flow 과정

 

참고: 우아한형제들 기술블로그 (우린 Git-flow를 사용하고 있어요)

  • master 브랜치에서 develop 브랜치를 분기
  • 개발자들은 develop 브랜치에 자유롭게 커밋
  • 기능 구현이 있는 경우 develop 브랜치에서 feature-* 브랜치를 분기
  • 배포를 준비하기 위해 develop 브랜치에서 release-* 브랜치를 분기
  • 테스트를 진행하면서 발생하는 버그 수정은 release-* 브랜치에 직접 반영
  • 테스트가 완료되면 release 브랜치를 master와 develop에 merge

출처 - https://puleugo.tistory.com/107 번역본 / https://techblog.woowahan.com/2553/ 원본

위 그림을 일반적인 개발 흐름으로 살펴보겠습니다.
처음에는 master와 develop 브랜치가 존재합니다. 물론 develop 브랜치는 master에서부터 시작된 브랜치입니다. develop 브랜치에서는 상시로 버그를 수정한 커밋들이 추가됩니다. 새로운 기능 추가 작업이 있는 경우 develop 브랜치에서 feature 브랜치를 생성합니다. feature 브랜치는 언제나 develop 브랜치에서부터 시작하게 됩니다. 기능 추가 작업이 완료되었다면 feature 브랜치는 develop 브랜치로 merge 됩니다. develop에 이번 버전에 포함되는 모든 기능이 merge 되었다면 QA를 하기 위해 develop 브랜치에서부터 release 브랜치를 생성합니다. QA를 진행하면서 발생한 버그들은 release 브랜치에 수정됩니다. QA를 무사히 통과했다면 release 브랜치를 master와 develop 브랜치로 merge 합니다. 마지막으로 출시된 master 브랜치에서 버전 태그를 추가합니다. -https://techblog.woowahan.com/2553/

728x90

RecyclerView란? 

 

RecyclerView는 안드로이드 앱에서 리스트 형태의 데이터를 표시하는데 사용되는 위젯이다. 여러 아이템을 스크롤 가능한 리스트로 표현하며, 많은 아이템을 효율적으로 관리하고 보여주는 역할을 한다.

  • RecyclerView는 한정적인 화면에 많은 데이터를 넣을 수 있는 View
  • View를 재활용해서 사용한다. 

ListView 와 RecyclerView 차이 

 

ListView 

  • 사용자가 스크롤 할 때마다 위에 있던 아이템은 삭제되고, 맨 아래의 아이템은 생성 되길 반복한다.
  • 아이템이 100개면 100이 삭제 생성된다. 즉 계속 삭제와 생성을 반복하므로 성능에 좋지않다.

RecyclerView

  • 사용자가 스크롤 할 때, 위에 있던 아이템은 재활용 돼서 아래로 이동하여 재사용 한다.
  • 즉 아이템이 100개여도 10개정도의 View만 만들고 10개를 재활용해서 사용한다.
  • View를 계속 만드는 ListView의 단점을 보완하기 위해 나왔다.

ListView//RecyclerView

RecyclerView사용하기

Adapter

  • Adapter란 데이터 테이블을 목록 형태로 보여주기 위해 사용되는 것으로, 데이터를 다양한 형식의 리스트 형식을 보여주기 위해서 데이터와 RecyclerView 사이에 존재하는 객체이다.
  • 즉 데이터와 RecyclerView 사이의 통신을 위한 연결체이다.

ViewHolder

  • ViewHolder란 화면에 표시될 데이터나 아이템들을 저장하는 역할이다.
  • RecyclerView의 개념을 적용하기위해선 스크롤 해서 위로 올라간 View를 재활용하기 위해서 이 View를 기억하고 있어야 한다. ViewHolder가 그역할을 한다.

예제 

activity_main.xml

더보기

 

<?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.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

item_profile.xml 

더보기
<?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"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <ImageView
        android:id="@+id/iv_profile"
        android:layout_width="70dp"
        android:layout_height="70dp"
        android:layout_margin="36dp"
        android:src="@drawable/image_1"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/tv_Name"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:text="이름 : "
        android:textSize="18sp"
        app:layout_constraintStart_toEndOf="@id/iv_profile"
        app:layout_constraintTop_toTopOf="@id/iv_profile" />

    <TextView
        android:id="@+id/tv_Age"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:layout_marginTop="8dp"
        android:text="나이 : "
        android:textSize="18sp"
        app:layout_constraintEnd_toEndOf="@id/tv_Name"
        app:layout_constraintTop_toBottomOf="@id/tv_Name" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/tv_NameText"
        app:layout_constraintTop_toTopOf="@id/tv_Name"
        app:layout_constraintBottom_toBottomOf="@id/tv_Name"
        android:text="김아무개"
        app:layout_constraintStart_toEndOf="@id/tv_Name"
        android:layout_marginStart="7dp"
        android:textSize="18sp"
        />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/tv_AgeText"
        app:layout_constraintTop_toTopOf="@id/tv_Age"
        app:layout_constraintBottom_toBottomOf="@id/tv_Age"
        android:text="17"
        app:layout_constraintStart_toEndOf="@id/tv_Name"
        android:layout_marginStart="7dp"
        android:textSize="18sp"
        />

</androidx.constraintlayout.widget.ConstraintLayout>

ItemAdapter

class ItemAdapter(private val list: List<Profile>) :
    RecyclerView.Adapter<ItemAdapter.ProfileViewHolder>() {
    inner class ProfileViewHolder(private val binding: ItemProfileBinding) :
        RecyclerView.ViewHolder(binding.root) {

        fun bind(profile: Profile) {
            binding.tvNameText.text = profile.name
            binding.tvAgeText.text = profile.age.toString()
        }
    }
    //뷰홀더 생성
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProfileViewHolder {
        val inflater =
            parent.context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
        val binding = ItemProfileBinding.inflate(inflater, parent, false)
        return ProfileViewHolder(binding)
    }
    //재사용 할 아이템 수
    override fun getItemCount() = list.size
    //데이터 연결
    override fun onBindViewHolder(holder: ProfileViewHolder, position: Int) {
        val profile = list[position]
        holder.bind(profile)
        //bind -> ProfileViewHolder의 bind(데이터 연결)
    }
}

 

데이터 클래스 

data class Profile(val name: String, val age: Int)

 

MainAcativity

class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding
    private lateinit var itemAdapter: ItemAdapter
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        //더미 데이터
        itemAdapter = ItemAdapter(listOf(Profile("김",16),Profile("나",22),Profile("박",65),Profile("이",23)))

        //recyclerView 세팅
        binding.recyclerView.apply {
            //adapter 연결
            adapter = itemAdapter
            //layout 설정 GridLayout 가능
            layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
        }
    }
}

 

구현화면

728x90

Activity(액티비티)  → Fragment(프래그먼트) 로 데이터 전송하는 경우 

 

메인액티비티

더보기
class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.run {
            btnFragment1.setOnClickListener {
                val data = "액티비티에서 보낸 데이터"
                val fragment1 = FirstFragment.newInstance(data)
                setFragment(fragment1)
            }
            btnFragment2.setOnClickListener {
                val data = "액티비티에서 보낸 데이터2"
                val fragment2 = SecondFragment.newInstance(data)
                setFragment(fragment2)
            }
        }

    }

    private fun setFragment(frag: Fragment) {
        supportFragmentManager.beginTransaction().apply {
            replace(R.id.fragment_Container, frag)
            setReorderingAllowed(true) // 애니메이션과 전환이 올바르게 작동하도록 트랜잭션과 관련된 프래그먼트의 상태 변경을 최적화
            addToBackStack("") // BackStack을 추가해줌 프래그먼트 뒤로가기 History
            commit()
        }
    }

}

 

FirstFragment

더보기
private const val ARG_PARAM1 = "param1"

class FirstFragment : Fragment() {
    private lateinit var binding: FragmentFirstBinding
    private var param1: String? = null
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        arguments?.let {
            param1 = it.getString(ARG_PARAM1)
        }
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = FragmentFirstBinding.inflate(inflater)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding.tvFragment1Text.text = param1

    companion object {
        fun newInstance(param1: String) =
            FirstFragment().apply {
                arguments = Bundle().apply {
                    putString(ARG_PARAM1, param1)
                }
            }
    }
}

SecondFragment

더보기
private const val ARG_PARAM2 = "param2"

class SecondFragment : Fragment() {
    private lateinit var binding: FragmentSecondBinding
    private var param2: String? = null
  
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        arguments?.let {
            param2 = it.getString(ARG_PARAM2)
        }
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = FragmentSecondBinding.inflate(inflater)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding.tvFragment2Text.text = param2
    }

    companion object {
        fun newInstance(param2: String) =
            SecondFragment().apply {
                arguments = Bundle().apply {
                    putString(ARG_PARAM2, param2)
                }
            }
    }
}

Fragment → Fragment 데이터 전송하는 경우 

 

FirstFragment

더보기
private const val ARG_PARAM1 = "param1"

class FirstFragment : Fragment() {
    private lateinit var binding: FragmentFirstBinding
    private var param1: String? = null
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        arguments?.let {
            param1 = it.getString(ARG_PARAM1)
        }
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = FragmentFirstBinding.inflate(inflater)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding.run {
            tvFragment1Text.text = param1

            btnSendToFragment2.setOnClickListener {
                val data = "From Fragment1 data"
                val fragment2 = SecondFragment.newInstance(data)
    
                requireActivity().supportFragmentManager.beginTransaction().apply {
                    replace(R.id.fragment_Container, fragment2)
                    addToBackStack(null) // BackStack을 추가해줌 프래그먼트 뒤로가기 History
                    commit()
                }
                
                
//                (activity as MainActivity).supportFragmentManager.beginTransaction().apply {
//                    replace(R.id.fragment_Container, fragment2)
//                    addToBackStack(null)
//                    commit()
//                }
                
                
                
            }

        }

    }

    companion object {
        fun newInstance(param1: String) =
            FirstFragment().apply {
                arguments = Bundle().apply {
                    putString(ARG_PARAM1, param1)
                }
            }
    }
}

Fragment Activity 데이터 전송하는 경우 (interface로 listener를 만들어야함)

더보기
private const val ARG_PARAM2 = "param2"

class SecondFragment : Fragment() {
    private lateinit var binding: FragmentSecondBinding
    private var param2: String? = null
    private var listener: DataReceiverListener? = null

    override fun onAttach(context: Context) {
        super.onAttach(context)
        if (context is DataReceiverListener) {
            listener = context
        } else {
            throw RuntimeException("$context")
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        arguments?.let {
            param2 = it.getString(ARG_PARAM2)
        }
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = FragmentSecondBinding.inflate(inflater)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding.run {
            tvFragment2Text.text = param2

            btnSendToActivity.setOnClickListener {
                val data = "From Fragment2 data"
                listener?.dataReceived(data)
            }
        }
    }

    companion object {
        fun newInstance(param2: String) =
            SecondFragment().apply {
                arguments = Bundle().apply {
                    putString(ARG_PARAM2, param2)
                }
            }
    }
}

interface DataReceiverListener {
    fun dataReceived(data: String)
}

MainActivity

더보기
class MainActivity : AppCompatActivity(),DataReceiverListener {
    private lateinit var binding: ActivityMainBinding
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.run {
            btnFragment1.setOnClickListener {
                val data = "액티비티에서 보낸 데이터"
                val fragment1 = FirstFragment.newInstance(data)
                setFragment(fragment1)
            }
            btnFragment2.setOnClickListener {
                val data = "액티비티에서 보낸 데이터2"
                val fragment2 = SecondFragment.newInstance(data)
                setFragment(fragment2)
            }
        }

    }

    private fun setFragment(frag: Fragment) {
        supportFragmentManager.beginTransaction().apply {
            replace(R.id.fragment_Container, frag)
            setReorderingAllowed(true)
            addToBackStack("")
            commit()
        }
    }

    override fun dataReceived(data: String) {
        Toast.makeText(this, "$data", Toast.LENGTH_SHORT).show()
    }
}

Fragment1  → Fragment2 → Fragment1 → Fragment2로 데이터 보내기 → Activity로 데이터 보내기

 

 

requireContext, requireActivity

Fragment 에서 context 나 activity 를 사용하는 상황이 fragment 가 attach 되서 정상적인 lifecycle 이라면 requireXXX 를 쓰는 것이 바람직하다.

728x90

달력, 시간 다이얼로그와 다이얼로그 프래그먼트  

class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        val calendar = Calendar.getInstance()
        val year = calendar.get(Calendar.YEAR)
        val month = calendar.get(Calendar.MONTH)
        val dayOfMonth = calendar.get(Calendar.DAY_OF_MONTH)
        
        //DatePickerDialog
        binding.btnDatePickerDialog.setOnClickListener {
            val listener = OnDateSetListener { _, year, month, dayOfMonth ->
                //날짜 선택 후 처리 
            }
            val datePicker = DatePickerDialog(this, listener, year, month, dayOfMonth)
            datePicker.show()
        }
        
        //TimePickerDialog
        binding.btnTimePickerDialog.setOnClickListener {
            val hourOfDay = calendar.get(Calendar.HOUR_OF_DAY)
            val minute = calendar.get(Calendar.MINUTE)
            val listener = OnTimeSetListener{_,hourOfDay,minute->
                //시간 선택 후 처리 
            }
            val timePickerDialog = TimePickerDialog(this,listener,hourOfDay,minute,false)
            timePickerDialog.show()
        }
        
        //DialogFragment 
        binding.btnFragmentDialog.setOnClickListener {
            MyDialogFragment().show(supportFragmentManager,"MyDialogFragment")
        }
        
    }
}

DataPickerDialog,TimePickerDialog

 

DialogFragment

 

DialogFragment 는 안드로이드에서 대화상자 형태의 UI를 구현할 때 사용하는 클래스이다. 이는 Fragment 클래스를 상속받아 모든 기능을 제공하면서, 추가로 대화상자를 관리하는 기능을 가지고 있다. DialogFragment를 사용하면 대화상자의 생명주기를 더욱 세밀하게 관리할 수 있으며, 화면 회전 같은 구성 변경 시에도 대화상자의 상태를 유지할 수 있다.

DialogFragment는 여러 가지 방법으로 사용될 수 있다. 일반적으로는 다음과 같은 용도로 사용된다:

  • 전통적인 대화상자
  • 부모 액티비티의 컨텍스트 내에서 실행되는 내장된 프래그먼트
  • 대화상자와 유사한 UI를 가진 전체 화면 프래그먼트

대화상자를 사용하는 전형적인 시나리오로는 사용자에게 결정을 요구하거나, 추가 정보를 입력받거나, 알림을 제공하는 등이 있다.

DialogFragment 예제 

class MyDialogFragment : DialogFragment() {

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        return activity?.let {
            val builder = AlertDialog.Builder(it)
            builder.setMessage("다이얼로그 프래그먼트")
                .setPositiveButton("네") { dialog, id ->
                    //positive 작업
                }
                .setNegativeButton("아니오") { dialog, id ->
                    //negative 작업
                }
            builder.create()
        } ?: throw IllegalStateException("Activity cannot be null") //예외처리 
    }
}

이 DialogFragment는 onCreateDialog 메서드를 오버라이드하여 AlertDialog를 생성하고 구성한다. 

 

 

MainActivity는 버튼을 클릭하면 MyDialogFragment를 표시한다. show 메서드는 FragmentManager와 대화상자의 태그를 인자로 받는다.

728x90

특정 프래그먼트에서 toolbar 를 안보여주고 싶을 때 onStart()와onDestroy에서 처리해주는 방법이 있다. 

+백그라운드로 gif 이미지를 처리하려고 할 때 Glide 라이브러리를 사용 

 

class ShortsFragment : Fragment() {
    private lateinit var binding: FramentShortsBinding
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = FramentShortsBinding.inflate(inflater)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        //글라이드 라이브러리. gif 처리 
        Glide.with(this).load(R.drawable.background).into(binding.ivBackground)
        
    }

    override fun onStart() {
        super.onStart()
        //toolbar , chipgroup 안보이게
        (activity as AppCompatActivity).supportActionBar?.hide()
        activity?.findViewById<ChipGroup>(R.id.chipGroup).let { chipGroup ->
            chipGroup?.isVisible = false // chipGroup 숨기기 
        }
    }

    override fun onDestroy() {
        //destroy 될때 다시 보여줌
        (activity as AppCompatActivity).supportActionBar?.show()
        super.onDestroy()
    }
}

toolbar가 보이는 Fragment / toolbar가 안보이는 Fragment

+ Recent posts