BottomNavigationView in Design SupportLibrary :

사용 하기 위해서는 다음과 같은 dependency 가 필요 하다. implementation ‘com.android.support:design:28.0.0’ 버전은 현재의 최신 버전으로 설정 했다.

Badge 기능을 사용하고 싶다면 다음과 같이 사용할 수 있다.

    private fun setBadge() {
        val bottomNavigationMenuView: BottomNavigationMenuView = bottomNavigationView.getChildAt(0) as BottomNavigationMenuView
        val bottomNavigationItemView  = bottomNavigationMenuView.getChildAt(0) as BottomNavigationItemView

        val badgeContainer = LayoutInflater.from(this).inflate(R.layout.view_badge, bottomNavigationMenuView, false)
        val firstBadgeCountText: TextView = badgeContainer.findViewById(R.id.count)
        firstBadgeCountText.text = "99"
        bottomNavigationItemView.addView(badgeContainer)
    }

BottomNavigationView의 getChildAt 0부터 첫번째 BottomNavigationItemView 다. inflate 한 view 를 bottomNavigationItemView에 추가해서 사용하면 됨.

BottomNavigationView in Material :

사용하기 위해서는 다음과 같은 dependency 가 필요 하다. implementation ‘com.google.android.material:material:1.1.0-alpha06’

이 버전에서는 BadgeDrawable 이 BottomNavigationView 안에 구현 되어 있어서 showBadge, removeBadge api를 통해 간편하게 사용 할 수 있다. 1.1.0-alpha05 이하 버전에서는 BadgeDrawable 이 구현 되어 있지 않아 showBadge, removeBadge 를 사용할 수 없는 걸 확인 했다.

사용방법은 Badge 구현 빼고는 Support BottomNavigationView 와 비슷했다.

    public void updateFirstBadge(int count) {
            if (count <= 0) {
                bottomNavigationView.removeBadge(R.id.title_first);
            } else {
                BadgeDrawable badgeDrawable = bottomNavigationView.showBadge(R.id.title_first);
                badgeDrawable.setBackgroundColor(getResources().getColor(R.color.red));
                badgeDrawable.setNumber(count);
            }
        }

2020년 03 월 12일 기준 최신 버전 implementation ‘com.google.android.material:material:1.2.0-alpha05’ 에서는 위의 showBade가 사라지고 getOrCreateBadge로 변경 되었다.
적용하면 아래와 같다.

    public void updateFirstBadge(int count) {
            if (count <= 0) {
                bottomNavigationView.removeBadge(R.id.title_first);
            } else {
                BadgeDrawable badgeDrawable = bottomNavigationView.getOrCreateBadge(R.id.title_first);
                badgeDrawable.setBackgroundColor(getResources().getColor(R.color.red));
                badgeDrawable.setNumber(count);
            }
        }

Firebase Distribute 를 사용하다가 배포된 apk 앱에서 최신 버전 유지가 필요해서 인앱업데이트 기능이 필요했다.
AppCenter 라는 Microsoft 에서 만든 sdk 가 있어서 이걸 사용해 보도록 했다.
Gradle Dependency 에는 다음과 같이 추가해 준다.

dependencies {
   def appCenterSdkVersion = '3.0.0'
   implementation "com.microsoft.appcenter:appcenter-distribute:${appCenterSdkVersion}"
}

그리고 인앱 업데이트 할 때 CustomDialog를 보여주기 위해 DistributeListener 인터페이스를 구현한 클래스를 하나만들어 준다.
클래스명은 임의로 InAppUpdateListener 라고 만들었다.

class InAppUpdateListener : DistributeListener {
    override fun onReleaseAvailable(
        activity: Activity?,
        releaseDetails: ReleaseDetails
    ): Boolean {
        activity?.apply {
            val dialogBuilder = AlertDialog.Builder(activity)
            dialogBuilder.setTitle(R.string.in_app_update)
            dialogBuilder.setMessage(R.string.msg_download_latest_version)
            dialogBuilder.setPositiveButton(R.string.common_confirm) { _, _ ->
                Distribute.notifyUpdateAction(UpdateAction.UPDATE)
            }
            dialogBuilder.setCancelable(false)
            dialogBuilder.create()
                .show()
            return true
        }
        return false
    }
}

Distribute.notifyUpdateAction(UpdateAction.UPDATE) 를 사용하게 되면 리턴을 true 로 셋팅 해준다.
return 을 false로 할 경우 액티비티 변경 될 때마다 콜백이 호출 될 수 있다.

Distribute.setUpdateTrack(UpdateTrack.PRIVATE)
Distribute.setListener(InAppUpdateListener())
AppCenter.start(application, BuildConfig.KEY_APP_CENTER_SECRET, Distribute::class.java)

테스트 그룹을 Private 으로 한 경우 첫번째줄 UpdateTrack 을 셋팅 해줘야 한다.
KEY_APP_CENTER_SECRET 는 앱의 시크릿 키 값이다. AppCenter Console -> Settings 우측 상단 메뉴 버튼 클릭

앱 업데이트가 필요한 곳에서 다음과 같이 호출

Distribute.setUpdateTrack(UpdateTrack.PRIVATE)
Distribute.setListener(InAppUpdateListener())
AppCenter.start(application, BuildConfig.KEY_APPCENTER_SECRET, Distribute::class.java)

유의할 점은 해당 versionCode 가 Distribute 에 업로드 되어 있다는 전제하에 업데이트 할 버전이 있다면 InAppUpdateListener onReleaseAvailable 가 호출 된다.
현재 versionCode 가 1 이고 Distribute 에 있는 버전이 2 버전이라면 콜백이 호출 되지 않는다.
Distribute 에는 1,2 현재버전까지 포함되서 올라가 있어야 호출됨.
그리고 Distribute 셋팅은 AppCenter.start 가 호출 되기 전에 호출 되어야 한다. 순서가 바뀔 경우 제대로 동작하지 않을수 있음.
AppCenterSecret Key 는 노출 되면 안되기 떄문에 환경변수에 저장해서 사용했다.

//앱 build.gradle

buildTypes {
        release {
            ...
        }

        debug {
            ...
        }

        buildTypes.each {
            it.buildConfigField 'String', 'KEY_APP_CENTER_SECRET', "\"$System.env.KEY_APP_CENTER_SECRET\""
        }
}

이번에 Github Actions 를 사용해서 Appcenter Distribute 에 apk 배포하는걸 자동화해서 작업을 했는데 그부분은 다음 글에서 올림.

화면 이동이나 특정 API 호출을 할 때 안드로이드 자체에서 다중 클릭을 막아 주지 않기 때문에 따로 처리가 필요하다.
그래서 회사 팀원들과 같이 의견을 조금씩 반영하다 보니 좋은 결과물이 나온것 같다. ㅎㅎ
소스는 아래와 같다.

var isClicked = false

//데이터 바인딩에서 사용하기 위한 fun
@BindingAdapter("app:onThrottleFirstClick", "app:onThrottleInterval", requireAll = false)
fun onThrottleFirstClick(
    view: View,
    onClickListener: View.OnClickListener,
    isWithoutInterval: Boolean = false
) {
    view.setOnClickListener { v ->
        if (isClicked.not()) {
            isClicked = true
            v?.run {
                if (isWithoutInterval) {
                    isClicked = false
                    onClickListener.onClick(v)
                } else {
                    postDelayed({
                        isClicked = false
                    }, 350L)
                    onClickListener.onClick(v)
                }
            }
        }
    }
}

//일반 뷰에서 사용할 수 있는 Kotlin Extension
fun View.onThrottleFirstClick(interval: Long = 350L, action: (v: View) -> Unit) {
    setOnClickListener { v ->
        if (isClicked.not()) {
            isClicked = true
            v?.run {
                postDelayed({
                    isClicked = false
                }, interval)
                action(v)
            }
        }
    }
}

xml Layout 코드(Before)

기존 onClick 구현 방식

<Button
    android:id="@+id/button"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:onClick="@{(v)-> 어떤 처리}"
    android:text="일반 클릭"
/>

위와 같이 onClick을 사용하면 다중 클릭을 막을 수 없고 버튼을 여러번 클릭 하는 경우 어떤 처리가 여러번 처리 된다.
아래 다중클릭이 방지된 BindingAdapter 를 사용하게 되면 여러번 클릭을 막을 수 있다.
onThrottleInterval 은 싱글 클릭이 필요 없는 경우에만 넣어주면 된다.
예를 들어 옵션의 카운트 갯수를 올려주는 행위에 사용하면 된다.

xml Layout 코드(After)

변경된 방식

<Button
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="싱글 클릭"
        app:onThrottleInterval="@{불린값}"
        app:onThrottleFirstClick="@{(v) -> 어떤 처리}"
        />

button을 가지고 있는 클래스(Before)

button.setOnClickListener {
    //어떤 처리
}

button을 가지고 있는 클래스(After)

button.onThrottleFirstClick {
    //어떤 처리
}

사용법도 크게 차이가 없어서 편하게 사용할 수 있게 만들어진 것 같다.

StartActivity 에서 DestActivity 로 String 데이터를 넘긴다고 가정하면 다음과같이 일반적으로 사용한다.

StartActivity 코드

val stringValue = "스트링 값"
Intent intent = new Intent(this, DestActivity.class)
            intent.putExtra(DestActivity.KEY_VALUE, stringValue)
            startActivity(intent)

DestActivity 코드

companion object {
    const val KEY_VALUE = "KEY_VALUE"
}

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    val stringValue = intent.getStringExtra(KEY_VALUE) ?: "",
}

Key값은 보통 DestActivity 에서 Static value 로 가지고 있고 StartActivity 에서 DestActivity 의 키 값을 사용한다.

또 다른 방법은 DestActivity 에서 innerClass 로 Arguments 클래스를 가지고 있는 방법이다.

class DestActivity{
  class Arguments(val stringValue: String) {
        fun startActivity(context: Context) {
            val intent = Intent(context, DestActivity::class.java)
            intent.putExtra(KEY_VALUE, stringValue)
            context.startActivity(intent)
        }

        companion object {
            private const val KEY_VALUE = "KEY_VALUE"

            fun createFromIntent(intent: Intent): Arguments {
                return Arguments(intent.getStringExtra(KEY_VALUE) ?: "")
            }
        }
    }
}

Arguments 사용법은 다음과 같다.

StartActivity 에서의 코드

DestActivity.Arguments(stringValue)
            .startActivity(this@BuyingBuildingsActivity)

DestActivity 코드

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    val binding = DataBindingUtil.setContentView(this, R.layout.activity_dest)
    val arguments = Arguments.createFromIntent(intent)
    binding.arguments = arguments
}

데이터 바인딩 (activity_dest.xml)

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:app="http://schemas.android.com/apk/res-auto">
    <data>
        <variable
            name="arguments"
            type="com.example.test.DestActivity.Arguments" />
    </data>
    <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{arguments.stringValue}"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

두번째 방법의 장점은 크게 3가지이다.

  1. 외부에서 DestActivity 에서의 키를 사용하지 않아도 된다.
    Arguments 클래스 내부에서 키 값 관리 하면 됨.
  2. 데이터 바인딩을 사용한다면 Arguments 를 레이아웃에서 바로 사용할 수 있다.
  3. intent 를 통해서 가져온 값은 Nullable 타입 이지만 Arguments 를 통해 가져온 변수는 NonNull 타입으로 변환해서 가져올 수 있다.

TODO : 선택된 아이템 알파벳들을 콤마로 구분해서 String 으로 만들기

사용 된 함수는 filterIsInstance , filter , map , joinToString
선언형 프로그래밍 -> 함수형 프로그래밍 방식으로 변경 하는 과정을 코딩

/**
 * Created by kimyounghoon on 2020-01-29.
 */
class KotlinLambdaCollectionTest {
    open class BaseItem(var id: String)

    class Item(id: String, val alphabet: String, val isSelected: Boolean) : BaseItem(id)

    lateinit var items: ArrayList<BaseItem>

    @Before
    fun before() {  //테스트 시작 되기 전에 불린다. 리스트 셋팅!!
        items =
            arrayListOf(
                Item("1", "a", false),
                Item("2", "b", true),
                Item("3", "c", true),
                Item("4", "d", true),
                Item("5", "e", false),
                Item("6", "f", false),
                Item("7", "g", false)
            )
    }


   /*
    *  test1 은 명령형 프로그래밍 2,3,4 함수형 프로그래밍으로 변환하는 작업입니다.
    * */
    @Test
    fun test1() {
        var alphabets = ""                  
        val filterItems = arrayListOf<Item>()
        for (item in items) {
            if (item is Item && item.isSelected) {
                filterItems.add(item)
            }
        }

        for (index in 0 until filterItems.size) {
            if (index == filterItems.size - 1) {
                alphabets += filterItems[index].alphabet
            } else {
                alphabets += filterItems[index].alphabet + ","
            }
        }

        Assert.assertEquals("b,c,d", alphabets)
    }

    /*
    *  filter 를 사용해서 Item 으로만 걸렀기 때문에 as List<Item> 으로 캐스팅. 뭔가 아쉽다.
    * */
    @Test
    fun test2() {
        var alphabets = ""
        (items.filter {
            (it is Item && it.isSelected)
        } as List<Item>).let {
            for (index in 0 until it.size) {
                if (index == it.size - 1) {
                    alphabets += it[index].alphabet
                } else {
                    alphabets += it[index].alphabet + ","
                }
            }
        }

        Assert.assertEquals("b,c,d", alphabets)
    }

    /*
    * filter 와 map , joinToString 을 사용하니 함수형 프로그래밍으로 바뀌고
    * 가독성도 많이 좋아졌는데 map 에서 한번더 캐스팅 해서 써야하는게 불편해서 구글링 한번더!!
    * */
    @Test
    fun test3() {
        val alphabets = items.filter {
            (it is Item && it.isSelected)
        }.map {
            (it as Item).alphabet
        }.joinToString(",")

        Assert.assertEquals("b,c,d", alphabets)
    }

    /*
    * filterIsInstance 를 사용하면 Item 만 필터 되어서 Item 형태로 반환 되기때문에 캐스팅이 필요 없다.
    * */
    @Test
    fun test4() {
        val alphabets = items.filterIsInstance<Item>().filter {
            it.isSelected
        }.map {
            it.alphabet
        }.joinToString(",")

        Assert.assertEquals("b,c,d", alphabets)
    }
}

test1 과 test4 를 비교 했을때 코드 양도 많이 줄고 훨씬 가독성이 많이 올라간걸 볼 수 있다.