반응형

1. ComponentActivity

  • 가장 기본적인 Activity 클래스 중 하나.
  • AndroidX에서 제공하는 최소한의 기능만 포함됨.
  • ViewModel, SavedState 같은 최신 아키텍처 컴포넌트 지원을 포함.
  • Fragment는 직접 다루지 않음.
  • JetPack Compose 의 경우 UI를 컴포저블 함수로 구현하기에 이 클래스의 기능만으로 구현 가능.

2. FragmentActivity (extends ComponentActivity)

  • ComponentActivity를 기반으로 하면서 Fragment를 사용할 수 있게 확장된 클래스.
  • SupportFragmentManager를 통해 프래그먼트 관리 기능을 제공.
  • androidx.fragment.app.Fragment 사용 시 필요.
  • Fragment 의 사용이 강조될 당시에 MainActivity 가 기본으로 상속받은 클래스.

3. AppCompatActivity (extends FragmentActivity)

  • FragmentActivity를 기반으로 하면서, AppCompat 지원 라이브러리 기능이 추가.
  • Toolbar, 다크모드, 테마 호환성, Material Design 등 현대적인 UI 요소를 쓸 수 있음.
  • 가장 일반적으로 사용하는 Activity 타입.
  • 전통적인 xml 파일을 이용한 View를 통해 UI 를 구현할 때 적합한 클래스.
  • Compose 개발방법을 사용하지 않았을 때 MainActivity 클래스가 기본으로 상속받는 클래스.

간단 요약

클래스별 주요기능 및 사용 목적

 

ComponentActivity ViewModel, SavedState 지원 최소 구조의 Activity
FragmentActivity + Fragment 지원 프래그먼트 기반 UI 사용 시
AppCompatActivity + AppCompat 기능 대부분의 일반 앱 개발 시 사용
반응형
반응형

EdgeToEdge UI 가 적용된 액티비티에서는

windowSoftInputMode="adjustResize" 설정이 적용되지 않습니다.

 

앱을 개발하다보면 회원가입, 게시글 작성 등 사용자 입력을 받는 화면을 구현할 경우가 있습니다.

입력받을 항목이 많다면 한 화면을 모두 사용하여 뷰를 배치하거나 스크롤뷰를 사용하게 됩니다. 이때 사용자 입력을 위해 EditText 나 머티리얼의 TextInputLayout 을 사용하게 되는데 글씨를 입력 하기 위해 소프트키보드가 밑에서 올라오게 됩니다. 화면의 상단에 배치한 입력용 뷰들은 크게 문제는 없지만 화면 하단에 배치한 EditText는 소프트키보드에 의해 가려지게 됩니다.

 

 

이럴 때 해결방법 중 하나가 activity에게 설정하는 android:windowSoftInputMode="adjustResize" 입니다. 많이 알려진 방법으로서 소프트키보드가 올라오면 액티비티의 사이즈를 남은 공간만큼 줄여줘서 입력뷰가 가려지지 않도록 하는 속성입니다.

 

AndroidManfest.xml

<activity
    android:name=".MainActivity"
    android:exported="true"
    android:windowSoftInputMode="adjustResize">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

 

하지만 EdgeToEdge UI 가 적용된 액티비티에서는 위 "adjustResize" 설정값이 적용되지 않습니다.

 

EdgeToEdge UI 에서는 시스템바(status bar, navigation bar) 영역에 뷰가 가려지지 않도록 동적으로 padding 을 적용하는 코드에 의해 adjustResize 설정값을 무시합니다.

 

♣ 해결방법

EdgeToEdge UI 에서는 시스템바와 같은 Insets 들의 사이즈를 동적으로 얻어와서 패딩을 통해 뷰들이 가려지지 않도록 하는 방법을 권장합니다.

 

Inset
큰 그림안에 있는 작은 그림이나 조각 같은 것을 나타냅니다.

 

Android에서 "Insets"는 화면의 일부를 차지하는 시스템 UI 요소(예: 상태 바, 내비게이션 바, 키보드 등) 입니다.

주요 Android Insets 종류

statusBars() 상단의 상태바(Status Bar) 영역
navigationBars() 하단 또는 측면의 내비게이션 바(Navigation Bar) 영역
ime() **소프트 키보드(IME)**가 차지하는 영역
systemBars() statusBars + navigationBars 의 조합
captionBar() 다이얼로그 또는 일부 윈도우에 있는 캡션 바 영역
displayCutout() 노치(notch) 또는 카메라 홀 등 디스플레이 컷아웃 영역
tappableElement() 시스템 제스처(예: 뒤로가기 등)에 방해되지 않아야 하는 탭 가능한 영역
mandatorySystemGestures() 사용자가 필수 시스템 제스처를 수행하는 영역
systemGestures() 일반 시스템 제스처 영역 (예: 풀스크린 제스처)
waterfall() 디스플레이 가장자리의 워터폴(edge) 스크린 영역

 

현재 소프트키보드에 의해 가려지는 TextInput 뷰를 해결하기 위해서는 ime() Insets 를 얻어와서 키보드의 높이만큼 아래쪽에 패딩을 적용하여 가려지지 않도록 만들어야 합니다.

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContentView(R.layout.activity_main)
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->

            //시스템바의 insets
            val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())

            //소프트키보드의 insets
            val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())

            //소프트키보드가 없다면( bottom 좌표가 0 ) 시스템바 사이즈 만큼만 아래(bottom) 영역을 주고 있다면 소프트키보드의 높이만큼 아래(bottom) 패딩 지정.
            if(imeInsets.bottom==0) v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
            else v.setPadding(systemBars.left, systemBars.top, systemBars.right, imeInsets.bottom)

            insets
        }
    }
}

 

이제 소프트 키보드가 올라오면 그만큼 액티비티의 아래쪽 패딩이 주어지게 되어 뷰들이 소프트키보드 영역에 가려지는 문제가 발생하지 않게 됩니다. 단, 스크롤뷰를 사용하지 않았다면 단순이 뷰들이 놓여지는 액티비티의 영역이 작아지는 것이기에 아래쪽의 뷰들을 볼 수 없게 되니 스크롤뷰로 입력창들을 감싸도록 해줘야 합니다.

반응형
반응형

안드로이드 API 35 버전에서는 Edge-To-Edge UI 디자인이 강제로 적용됩니다.

35버전 이전의 디바이스와 35버전 이상의 디바이스는 같은 레이아웃 코드를 작성해도 Edge-To-Edge UI 로 인해 다르게 보이게 됩니다.

그렇기에 35버전의 디바이스와 같은 UI로 보이고 싶다면 Edge To Edge UI를 적용해야만 합니다.

 

Edge-To-Edge UI 

액티비티는 기본적으로 디스플레이 화면 전체를 사용하지 않습니다. 상태표시줄(Status bar)네비게이션바(Navigation bar) 영역을 제외한 부분만을 사용합니다. 예전에는 화면전체를 사용하려면 WindowManager 를 통해 Flag 설정을 직접 해야 했습니다. 하지만 안드로이드 버전이 변경되면 기존 Flag 설정으로 적용되지 않는 등의 문제가 있어 개발자들을 조금 불편하게 했습니다. 

구글은 컴포즈 개발방법을 도입하면서 UI 적으로 화면 전체를 사용하게 함으로서 사용자에게 몰입감을 주고자 화면을 상단 끝(Edge)에서 하단 끝(Edge) 까지 사용하는 것을 권장합니다. 그래서 화면 전체 사용을 편하게 적용하게 하기 위해 enableEdgeToEdge() 기능을 제공하며 이를 기본 보일러플레이트 코드(미리 작성되어 있는 코드)로 추가하였습니다.

Android 15(api 35)버전 부터는 명시적인 enableEdgeToEdge() 사용 여부와 상관없이 강제로 적용됩니다. 

 

 

위 그림에서 보듯이 Edge to Edge UI는 액티비티 영역이 시스템바(status bar & navigation bar) 영역까지 확장되었기에 화면의 상단 또는 하단에 뷰를 배치하면 시스템바에 의해 가려지는 문제가 발생합니다. 즉, 시스템바는 없어진 것은 아니고 액티비티 위에 오버레이 되어 그려집니다. 그렇기에 배경색이나 배경 이미지를 제외한 뷰들의 배치는  통상적으로 시스템바에 가려지지 않도록 최상위뷰(root view - id 가 R.id.main)에 안쪽 여백을 설정하는 padding 을 적용해줍니다. 

다만, 네비게이션 바의 종류(제스처, 2버튼, 3버튼)나 디바이스의 종류에 따라 시스템바의 사이즈가 다르기에 리스너를 통해 시스템바의 사이즈를 동적으로 얻어와서 패딩을 적용합니다. 즉. 레이아웃 xml 파일에서 지정하지 않고 코틀린파일에서 코드로 지정하는 방식을 사용합니다. 이 또한 프로젝트를 생성하면 자동으로 작성되어 있는 보일러플레이트 코드로 되어 있습니다.

덕분에 코드가 지저분해 보이기도 합니다. 

 

MainActivity.kt

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        //시스템바(status bar, navigation bar) 영역까지 액티비티의 영역을 확장(api35버전 부터 명시적 적용여부와 상관없이 강제로 적용)
        enableEdgeToEdge()

        //액티비티가 보여줄 레이아웃 뷰를 지정
        setContentView(R.layout.activity_main)

        //액티비티가 시스템바 영역까지 확대되면 시스템바에 의해 뷰들이 가려짐. 이에 최상위 뷰(R.id.main)를 찾아와서 시스템바 사이즈를 동적으로 얻어와 패딩을 적용.
        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
        }
    }
}

 

새로 추가된 EdgeToEdge.enable(), setOnApplyWinowInsetsListener() 메소드의 역할을 조금 더 잘 이해하기 위해 예전처럼 레이아웃 뷰를 설정하는 setContentView()만 두고 나머지는 지우고 실행해서 차이점을 확인해 보겠습니다. 

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        //액티비티가 보여줄 레이아웃 뷰를 지정
        setContentView(R.layout.activity_main)

    }
}

 

EdgeToEdge 코드를 제거했기에 api 34 버전까지의 디바이스에서는 시스템바(status bar, navigation bar)의 영역을 제외한 부분만 액티비티가 사용됩니다. api 35 버전에서는 EdgeToEdge 의 명시적인 적용여부와 상관없이 무조건 반영되기에 시스템바 영역까지 액티비티의 영역이 확장됩니다. 적용여부의 차이를 시각적으로 구분하기 위해 AVD 2대를 동시에 실해하여 비교해 보겠습니다. Android Studio는 한번에 여러대의 phone 을 실행할 수 있습니다. 다만 기본적으로는 웹 브라이저의 탭처럼 어려탭으로 나뉘어 한번에 하나의 디바이스 화면만 보입니다. 이를 동시에 좌우로 보기위해 애뮬레이터의 탭제목 영역을 우클릭하여 spilt 된 좌우 화면으로 볼 수 있습니다.

api 34 & api 35 디바이스 동시 실행

 

♣ 2대의 디바이스를 동시에 실행하고 좌우로 분리하여 보기

 

 

이제 준비가 되었으니 EdgeToEdge 가 적용되지 않았을때 35버전 이전과 이후가 다른 점을 확인해 보겠습니다. 동시에 실행해야 하는 만큼 디바이스 target 을 변경하면서 run 을 하겠습니다.

실행 디바이스 선택

 

api 34 버전과 api 35 버전의 실행결과 차이 확인

 

위 그림 좌측에 보여지는 34 버전의 경우에는 화면 상단의 보라색 영역인 상태표시줄과 화면 하단의 검정색 네비게이션바 영역을 제외한 부분만 액티비티가 차지하기에 하얀색 배경 테마가 시스템바를 제외한 부분만 차지합니다. EdgeToEdge 가 적용되지 않았을때 모습입니다.

이에 반해 그림 우측에 보여지는 35 버전의 경우에는 하얀색 테마의 액티비티가 하면 상단,하단의 시스템바(status bar, navigation bar)영역까지 확장되어 있어 화면 전체를 사용하는 모습을 볼 수 있습니다. 디스플레이 전체를 사용하기에 사용자가 화면에 대한 몰입감이 향상된다고 합니다.

결국, 같은 코드를 실행해도 35 버전 이전과 이후가 다르게 보이게 됩니다. 그렇기에 모든 사용자에게 같은 UI를 제공하기 위해서는 어쩔 수 없이 EdgeToEdge 를 적용해야만 합니다. 물론 설정을 통해 35 버전에서도 EdgeToEdge 를 사용하지 않도록 설정할 수도 있기는 합니다만 권장하지는 않습니다.

 

이제 EdgeToEdge UI 를 사용해보겠습니다. 패딩을 적용하기 위한 Insets 는 아직 적용하지 않겠습니다.

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        //시스템바(status bar, navigation bar) 영역까지 액티비티의 영역을 확장(api35버전 부터 명시적 적용여부와 상관없이 강제로 적용)
        enableEdgeToEdge()

        //액티비티가 보여줄 레이아웃 뷰를 지정
        setContentView(R.layout.activity_main)

    }
}

 

EdgeToEdge 를 적용했을 때 34 버전과 35 버전의 실행모습 확인

 

보다시피 네비게이션 바의 색상만 약간 회색일 뿐 같은 UI 구조를 보여주기에 사용자에세 통일감 있는 화면을 제공하기에 이제 Edge 적용은 선택이 아닌 필수라고 할 수 있습니다.

 

다만 액티비티의 영역이 화면 상단과 하단까지 차지하기에 시스템바에 의해 가려지는 부분이 발생합니다. 그렇기에 뷰의 배치를 상단 또는 하단에 위치하면 시스템바에 의해 가려지는 문제가 발생합니다.

확인을 위해 TextView를 화면 하단으로 이동하겠습니다. 글씨가 잘 보이도록 글씨 크기를 조금 키우도록 하겠습니다.

TextVeiw의 constraint 속성 중 app:layout_constraintTop_toTopOf="parent" 를 제거 하도록 하겠습니다.

<?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:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        android:textSize="64sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

 

 

EdgeToEdge UI 로 인해 텍스트뷰를 가리는 네비게이션바 

 

이를 해결하기 위해 시스템바 영역을 제외한 크기만큼 최상위 뷰(R.id.main)에 padding 을 적용해 줘야 합니다. 다만, 시스템 바의 사이즈는 디바이스나 네비게이션바의 종류에 따라 달라 질 수 있기에 xml 레이아웃파일에서 적용하지 않고 코틀린코드에서 동적으로 적용해 줘야 합니다.

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        //시스템바(status bar, navigation bar) 영역까지 액티비티의 영역을 확장(api35버전 부터 명시적 적용여부와 상관없이 강제로 적용)
        enableEdgeToEdge()

        //액티비티가 보여줄 레이아웃 뷰를 지정
        setContentView(R.layout.activity_main)

        //액티비티가 시스템바 영역까지 확대되면 시스템바에 의해 뷰들이 가려짐. 이에 최상위 뷰(R.id.main)를 찾아와서 시스템바 사이즈를 동적으로 얻어와 패딩을 적용.
        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
        }
    }
}

 

이제 시스템바의 사이즈 만큼 패딩이 적용되어 TextView 가 가려지지 않는 것을 확인할 수 있습니다.

 

참고로 setContentView() 와 setOnApplyWindowInsetsListener()의 적용 순서는 바뀌면 안됩니다. setContentView()가 실행되어야 xml 에 작성한 뷰 객체가 만들어 지고 그래야 Insets 를 적용할 최상위 뷰 객체를 찾아올 수 있기에 순서가 바뀌면 null point 예외가 발생하게 됩니다. 즉, 최초 프로젝트를 생성할 때 작성되어 있는 코드를 수정하지 않고 그냥 사용하시면 됩니다.

 

※ 종합 정리

android api 35 버전의 디바이스 부터는 명시적인 사용설정 여부와 상관없이 강제로 화면을 모두 사용하는 EdgeToEdge UI 가 적용됩니다. 그렇기에 enableEdgeToEdge() 를 적용하지 않는 앱을 만들게 되면 같은 앱을 다운받은 사용자들이라고 하더라고 사용하는 폰의 안드로이드 버전에 따라 다르게 보일 수 있게 됩니다.

 

그렇기에 이제는 선택의 여지 없이 enableEdgeToEdge()를 적용 할 수 밖에 없다고 보는게 좋을 것 같습니다.

 

 

반응형
반응형

앱을 개발할 때 클릭과 같은 이벤트 처리를 위해 익명클래스를 만드는 경우가 많습니다.

익명클래스는 작성 코드가 다소 지저분합니다. 클래스의 중괄호 안에 메소드 오버라이드를 위한 중괄호가 또 다시 존재하여 중첩구조가 되어 가독성도 떨어집니다. 그래서 람다표현으로 축약하여 작성하는 경우가 많습니다. 코틀린은 이 람다표현식을 적용할 때 보다 간결하게 작성해주는 SAM 변환 문법을 제공합니다.

SAM Conversion

  • SAM은 Single Abstract Method의 약자로 코틀린의 함수 리터럴을 자동으로 자바의 함수형 인터페이스로 교체해줌.
  • 즉, SAM Conversion은 자바로 작성한 Functioncal Interface에서만 동작하며 코틀린으로 작성시에는 사용하지 못함.
  • 이 기능은 자바와 상호 운영성 측면에서 나왔으며 코틀린의 경우 함수형 인터페이스가 아닌 함수타입으로 선언이 가능함.

함수형 인터페이스 Functional Interface

  • 추상메소드가 1개만 있는 인터페이스
  • default method 와 static method 는 여러개 존재해도 됨
  • 함수형 인터페이스만 람다 표현식으로 작성 가능. 즉, 추상메소드가 1개인 경우에만 람다 표현식이 가능함
  • @FunctionalInterface 어노테이션을 통해 함수형 인터페이스인지 검증 할 수 있음.

 

예제를 만들어보면서 SAM변환에 대해 알아보겠습니다.

 

Project Type : Empty Views Activity

  • Name : KotlinSamConversion
  • Language : Kotlin
  • Mininum SDK : API 26
  • Build configuration language : Kotlin DSL

 

버튼 클릭 처리에 방법에 대해 알아보기 위해 레이아웃 xml 파일에 버튼 하나를 배치하도록 하겠습니다.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/btn"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="button"/>

</LinearLayout>

 

늦은 초기화를 이용하여 버튼을 참조하겠습니다.

class MainActivity : AppCompatActivity() {

    // 늦은 초기화로 버튼 참조하기
    lateinit var btn: Button

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContentView(R.layout.activity_main)
        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
        }

        //lateinit var 변수 초기화
        btn= findViewById(R.id.btn)
        
    }
}

 

 

버튼 클릭이벤트에 리스너를 등록하는 3가지 방법에 대해 알아보겠습니다. 기본적인 익명클래스 부터 람다식, SAM 변환으로 조금씩 코드를 축약하여 작성하는 모습을 통해 SAM 변환에 대한 효용성을 인식하도록 해보겠습니다. 

 

1) 기본적인 익명클래스로 객체를 생성하여 리스너로 등록

View.OnClickListener 는 인터페이스 이기에 곧바로 객체를 생성할 수 없습니다. 안에 onClick 이라는 추상메소드를 구현하는 별도의 클래스를 정의하고 객체로 생성해야 합니다. 하지만 btn 에만 사용되기에 굳이 별도의 class 를 만들어 재사용 할 필요가 없기에 보통 객체를 생성하면서 클래스의 내용을 구현하는 익명클래스를 이용하여 클릭이벤트를 처리합니다. 코틀린에서는 object 키워드를 이용하여 익명클래스를 구현합니다. 클릭했을 때 동작은 간단하게 Toast 를 보여주도록 하겠습니다. 주의할 점은 Toast를 보여줄 때의 첫번째 파라미터로 필요한 Context 로 보통 액티비티를 지정해 주는데 익명 클래스 안에서 this는 익명클래스 본인을 의미하기에 이를 감싸는 아웃터 클래스인 MainActivity 클래스를 의미하고자 한다면 @Label 문법을 사용하여 this@MainActivity 로 지정해야 합니다. 자바에서도 많이 사용했던 문법인데 자바에서는 MainActivity.this 로 사용했던 문법이었습니다.

//버튼 클릭이벤트 리스너 등록 3가지 방법 [ 익명객체 만들어 적용하는 3가지 방법 ]
//1. 기본적인 익명객체 문법
btn.setOnClickListener(object : View.OnClickListener{
    override fun onClick(v: View?) {
        //익명 클래스안에서 아웃터클래스(MainActivity)의 this를 호출할때 @Label 사용
        Toast.makeText(this@MainActivity, "clicked", Toast.LENGTH_SHORT).show()
    }
})

 

2) 람다( Lambda) 표기법

익명클래스의 추상메소드가 1개일때 사용이 가능한 문법으로서 코드에 대한 축약표현문법입니다. 람다표현은 추상메소드의 오버라이드 코드를 생략하여  가독성도 좋고 코드도 간결해 집니다. 또한 람다의 { } 영역은 익명클래스가 아닌 일반 함수의 {} 영역처럼 되기에 이 영역안에서는 this 가 람다를 가진 MainActivity를 의미하기에 @Label 이 필요하지 않습니다.

//2. 람다(Lambda)표기법 [ 익명객체의 추상메소드가 1개일때 사용가능한 기술 - 축약표현문법 : 추상메소드의 오버라이드 코드를 생략 -람다표기법 사용 ] {}는 익명클래스가 아니기에 this 가 MainActivity를 의미함.
btn.setOnClickListener( View.OnClickListener { v-> Toast.makeText(this, "SAM conversion clicked", Toast.LENGTH_SHORT).show() }  )

 

2.1) 파라미터가 1개일 때는 파라미터의 생략도 가능합니다. 그래서 v-> 를 생략하면 코드가 더 간결해집니다.

//2.1 파라미터가 1개일때는 파라미터도 생략가능
btn.setOnClickListener( View.OnClickListener { Toast.makeText(this, "SAM conversion clicked!", Toast.LENGTH_SHORT).show() }  )

 

3) SAM 변환 

람다표현에서 setOnClickListener()메소드의 리스너에 다른 클래스를 사용하는 것은 어차피 불가능 하기에 View.OnClickListener 인터페이스이름 조차도 생략하고 함수의 소괄호()까지 제거하여 클릭 했을 때 수행할 내용만 신경쓰며 작성할 수 있도록 더 축약형으로 작성하는 것이 가능합니다. 이를 SAM 변환(Conversion) 이라고 합니다. 이름 그대로 추상메소드가 1개인 경우에만 적용이 가능한 변환 표기법 입니다. 

//3. SAM 변환 - setOnClickListener()메소드가 어차피 다른 클래스는 사용이 불가능 하기에 OnClickListener 인터페이스이름 조차도 생략하여 더 축약형으로..
btn.setOnClickListener { v-> Toast.makeText(this, "완전 SAM 적용", Toast.LENGTH_SHORT).show() }

 

3.1) 파라미터가 1개일때 생략 가능

당연하게 SAM 변환도 파라미터가 1개일때는 파라미터 표기를 생략하는 것이 가능합니다. 계속 Toast 만 보여주었으니 이번에는 사용자 데이터를 입력받아 보여주는 코드를 작성해 보겠습니다.

사용자가 글씨를 입력하고 버튼을 클릭하면 입력된 글씨를 그대로 보여주는 간단한 기능을 구현하겠습니다. EditText와 TextView를 추가하겠습니다. 버튼을 클릭했을 때 EditText의 글씨를 가져와서 TextView에 설정하도록 하겠습니다.

먼저 레이아웃 activity_main. xml 파일에 뷰를 추가하겠습니다.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp"
    tools:context=".MainActivity">

    <EditText
        android:id="@+id/et"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="input text"
        android:inputType="text"/>
    <Button
        android:id="@+id/btn"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="button"/>
    <TextView
        android:id="@+id/tv"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:padding="8dp"
        android:text="RESULT"
        android:textColor="@color/black"/>

</LinearLayout>

 

MainActivity 코틀린 파일에서 뷰 참조변수 2개를 추가하고 참조하도록 하겠습니다. 위 Button 객체의 참조변수 btn 은  lateinit 늦은 초기화를 사용했으니 이번에는 다른 방법의 늦은 초기화로 참조하도록 하겠습니다.

class MainActivity : AppCompatActivity() {

    // 늦은 초기화로 버튼 참조하기
    lateinit var btn: Button

    //다른 방법으로 늦은 초기화
    val et: EditText by lazy { findViewById(R.id.et) }
    val tv by lazy { findViewById<TextView>(R.id.tv) }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        .....

        //lateinit var 변수 초기화
        btn= findViewById(R.id.btn)
        
        .....
        
    }
}

 

이제 버튼 클릭이벤트 처리를 SAM 변환으로 하겠습니다. 또한 onClick 함수의 파라미터가 v 한개뿐이기에 생략하도록 하겠습니다.

코틀린은 getXXX(), setXXX()을 사용하지 않고 곧바로 property(멤버변수)를 제어하는 방식을 선호합니다. 이를 참고하여 코드를 보시기 바랍니다.

//3.1 당연히 파라미터 생략가능 [ EditText의 글씨를 가져와서 TextView에 설정 - 코틀린의 getXXX(), setXXX()을 사용하지 않고 곧바로 property(멤버변수)를 제어하는 방식을 선호 ]
btn.setOnClickListener { tv.text= et.text.toString() }

 

3.2) 만약 생략한 파라미터 v 를 사용하고 싶다면 it 키워드 사용 가능

클릭된 버튼의 id 프로퍼티에 접근 할 때 it 키워드를 사용해 보았습니다.

//3.2 만약 파라미터를 생략했는데 {}안에서 파라미터 v를 사용하고 싶다면 특별한 키워드 it 사용
btn.setOnClickListener {
    when(it.id){
        //익명객체를 생략했기에 Context파라미터에 전달한 this가 자연스럽게 MainActivity가 되어 그냥 사용할 수 있음.
        R.id.btn-> Toast.makeText(this, "선택한 버튼의 ID 값 : " + it.id, Toast.LENGTH_SHORT).show()
    }
}

 


 

SAM은 리스너중에서 추상메소드가 1개인 모든 곳에서 사용이 가능합니다.

롱클릭, 체크박스, 라디오그룹, 레이팅바 등 에서도 사용이 가능합니다. 하나씩 살펴보면서 SAM 변환에 대해 익숙해저 보겠습니다.

 

1) LongClickListenr

주의깊게 보실 것은 return 키워드를 생략한 부분입니다. LongClickListener 는 ClickListener의 중간 과정이라고 볼 수 있기에 롱클릭이 종료되면 이어서 클릭이벤트가 발동합니다. 즉, 롱클릭 처리를 한 후에 원치않아도 클릭이벤트가 발동되기에 이벤트를 이 곳에서 멈추기위해 이벤트를 소비했다는 의미에서 true 를 리턴해줘야 합니다. 이 때 SAM 변환을 한 중괄호 { } 영역은 별도의 함수 영역이 아니기에 리턴을 위해 return 키워드를 작성하면 LongClickListener 가 아닌 이 코드가 작성되고 있는 onCreate method 영역의 return 으로 인식됩니다. 그래서 SAM 변환 영역 안에서 리턴을 하려면 return 키워드를 생략해야만 합니다. 만약, 명시적으로 return 키워드를 명시하고 싶다면 @Label 을 통해 return 하는 주체가 setOnLongClickListener 임을 명시적으로 알려줘야만 합니다.

//1) LongClickListener
btn.setOnLongClickListener {
    Toast.makeText(this, "long click", Toast.LENGTH_SHORT).show()

    //return this //error - 여기서 return 을 하면 onCreate Method 의 return 으로 인식함.
    //SAM 변환에서는 return 키워드 사용하지 않음.
    true
    // return 키워드를 명시적으로 하고싶다면.. @Label을 통해 누구의 return 인지 명시..
    //return@setOnLongClickListener true   //리턴값이 있을때의 코드
}

 

 

2) 복합버튼(CompoundButton - CheckBox, RadioButton, Switch) 의 체크상태 변경 리스너 OnCheckedChangeListener

먼저 복합버튼 중에서 가장 많이 사용되는 CheckBox 를 레이아웃 xml 에 추가하겠습니다.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp"
    tools:context=".MainActivity">

    ........

    <!-- SAM 연습용 복합버튼.   -->
    <CheckBox
        android:id="@+id/cb"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="checkbox"/>

</LinearLayout>

 

이제 체크박스의 변경에 반응하는 리스너를 설정하겠습니다. SAM 변환과정에서 필요한 파라미터를 확인하기 위해 한번은 전통적인 익명클래스로 설정하고 이를 다시 SAM 변환으로 리스너를 설정해보겠습니다.

 

먼저 체크박스 뷰를 참조하겠습니다. 리스너 설정에 집중하기 위해 참조변수는 onCreat() method 안에서 만들고 참조하겠습니다.

class MainActivity : AppCompatActivity() {

    ...

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        .....

        val cb: CheckBox = findViewById(R.id.cb)
        
    }
}

 

2.1) 익명클래스 객체를 이용하여 체크상태 변경 리스너 설정하기

cb.setOnCheckedChangeListener( object : CompoundButton.OnCheckedChangeListener{
    override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) {
        Toast.makeText(this@MainActivity, "체크상태 : $isChecked", Toast.LENGTH_SHORT).show()
    }
})

 

체크상태 변경 콜백메소드인 onCheckedChanged() 메소드는 체크박스 참조변수와 체크상태값을 파라미터로 받게 됩니다.

이 OnCheckedChangeListener 인터페이스도 추상메소드가 1개 뿐인 함수형 인터페이스 입니다. 그렇기에 다른 함수가 리스너에 설정될 수 없습니다. 그렇기에 굳이 인터페이스 이름과 콜백메소드를 명시하지 않고 함수 영역인 중괄호{ } 만 작성하는 것으로 간략하게 작성하는 SAM 변환이 가능합니다.

 

2.2) SAM 변환으로 체크상태 변경 리스너 설정하기

OnClickListener 와 다르게 파라미터가 2개 이기에 생략하는 것은 불가능 합니다. 즉, 아래 코드는 에러입니다.

//SAM conversion
//파라미터가 여러개면 생략할 수 없음 , 파라미터들의 변수명만 작성해도 되고. 자료형까지 명시해도 됨
cb.setOnCheckedChangeListener{ Toast.makeText(this, "aaa", Toast.LENGTH_SHORT).show() } //ERROR

 

2개의 파라미터를 작성하되 자료형은 반드시 명시할 필요는 없습니다. 물론 명시해도 상관은 없습니다.

cb.setOnCheckedChangeListener{ buttonView, isChecked:Boolean ->
    Toast.makeText(this, "체크상태 : $isChecked", Toast.LENGTH_SHORT).show()
}

 

코드가 훨씬 간결해 보이네요. 

이번에는 다중선택이 가능한 체크박스와 다르게 단일선택(Single choice) 용 RadioButton 에 대한 처리를 SAM 변환으로 해보겠습니다.

 

 

3) RadioGroup 의 체크상태 변경 리스너 RadioGroup.OnCheckedChangeListener

RadioButton 은 여러개 중 한개만 선택되는 단일 선택이기에 하나를 선택하여 체크상태가 변경되면 다른 RadioButton의 체크상태도 해제되는 변경이벤트가 발생합니다. 즉, 라디오버튼 1개를 선택하면 다른 라디오버튼의 체크가 해제됩니다. 그렇기에 다중선택 용으로 사용하는 체크박스와 다르게 버튼 개별 단위로 체크상태변경 리스너를 처리하지 않습니다.

여러 라디오 버튼을 감싸는 RadioGroup 단위로 체크상태 변경 처리하여 선택된 RadioButton 이 누구인지 식별하여 원하는 작업을 수행하도록 합니다.

 

체크박스 아래에 RadioGroup 과 RadioButton 2개를 레이아웃 파일에 추가하겠습니다. 성별(남성/여성)을 선택하는 버튼들 입니다.

.....

<RadioGroup
    android:id="@+id/rg"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal">

    <RadioButton
        android:id="@+id/rb_f"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="female"
        android:checked="true"/>
    <RadioButton
        android:id="@+id/rb_m"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="male"/>

</RadioGroup>

 

체크박스때와 마찬가지로 onCreate() method 안에 RadioGroup 참조변수를 만들고 뷰를 참조하겠습니다.

val rg: RadioGroup = findViewById(R.id.rg)

 

3.1) RadioGroup에  익명클래스로 체크상태 변경 리스너 설정하기

rg.setOnCheckedChangeListener(object : RadioGroup.OnCheckedChangeListener{
    override fun onCheckedChanged(group: RadioGroup?, checkedId: Int) {
        val rb: RadioButton = findViewById(checkedId)
        Toast.makeText(this@MainActivity, rb.text, Toast.LENGTH_SHORT).show()
    }
})

 

라디오그룹의 체크상태 변경 리스너의 onCheckedChanged() 콜백메소드도 2개의 파라미터를 가집니다. 다만, 체크박스의 콜백메소드와 파라미터 종류가 다릅니다. 첫번째는 RadioGroup 참조변수와 현재 선택된 RadioButton의 id 속성값을 파라미터로 받습니다.

이 2번째 파라미터인 선택된 라디오버튼의 id 를 이용하여 RadioButton 뷰를 참조하고 이 뷰의 글씨를 얻어와 보여줄 수 있습니다.

 

이 라디오그룹의 리스너 인터페이스도 콜백메소드가 1개뿐이기에 함수 영역 { } 만 작성하는 SAM 변환이 가능합니다.

 

3.2) SAM conversion 으로 체크상태 변경 리스너 설정하기

파라미터의 변수명만 작성해도 됩니다. 1개가 아니면 생략할 수 없습니다.

//SAM conversion
rg.setOnCheckedChangeListener { group, checkedId ->
    val rb: RadioButton = findViewById(checkedId)
    Toast.makeText(this, "선택 : ${rb.text}", Toast.LENGTH_SHORT).show()
}

 

역시 이벤트 처리 코드가 아주 간결합니다.

이렇듯 인터페이스의 추상메소드가 1개뿐이라면 SAM 변환을 사용하는 것으로 코드의 간결함이 매우 좋아지기에 많이 사용됩니다.

 

그런데 SAM 변환( Single Abstract Method ) 라는 이름이 무색하게 추상메소드가 1개가 아닌 곳에서도 SAM 변환 적용이 가능한 경우가 있습니다.

 

4) 특이하게 추상메소드가 1개가 아님에도 사용할 수 있는 경우도 있음.

SAM이라는 이름이 좀 어색합니다.

사용자 입력을 받는 EditText 의 글씨 변경 이벤트 처리는 TextWatcher 인터페이스를 이용합니다. 이 인터페이스는 글씨변경전, 변경될 때, 변경 후에 각각 발동하는 콜백용 추상메소드 3개를 가지고 있습니다. 즉, Single Abstract Method 가 아닙니다. 그럼에도 SAM 변환이 가능합니다. 이 3개 중 마지막 1개의 추상 메소드[ afterTextChanged() ]를 SAM 변환으로 처리할 수 있습니다.

 

위에서 사용했던 EditText 를 참조했던 et 참조변수를 이용하여 실습을 진행하겠습니다.

 

4.1) TextWatcher 익명 클래스를 통해 글씨변경 이벤트 처리하기

et.addTextChangedListener( object : TextWatcher {
    override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
        //자동으로 써진 TODO 를 지우거나 앞에 주석표시 없으면 이 영역이 실행될 때 에러 발생할 수 있음.
        //TODO("Not yet implemented")
    }

    override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
        //TODO("Not yet implemented")
    }

    override fun afterTextChanged(s: Editable?) {
        //TODO("Not yet implemented")
    }
})

 

위 익명클래스의 코드를 보면 알 수 있듯이 3개의 추상 메소드를 가지고 있습니다. 이 중 마지막에 있는 afterTextChanged() 메소드만을 사용한다면 SAM 변환으로 간략하게 작성이 가능합니다.

 

4.2) SAM 변환으로 글씨변경 이벤트 처리하기

//위 처럼 추상메소드가 3개인 인터페이스는 원래 SAM이 안됨. 하지만, addTextChangedListener()에서는 3개중 마지막 추상메소드만 사용하는 인터페이스로 변경하여 SAM처리해줌
//즉,아래 SAM변환 메소드는 위 TextWatcher의 afterTextChanged() 추상메소드 임
et.addTextChangedListener{
    Toast.makeText(this,"글씨변경 : " + it.toString(),Toast.LENGTH_SHORT).show()
}

 

 

5) 뷰의 이벤트 처리 외에 다이얼로그의 버튼 이벤트 처리에도 SAM conversion 사용 가능

뷰의 이벤트 처리에 사용되었던 리스너 익명클래스들 외에도 추상메소드 1개를 가진 익명클래스가 사용되는 모든 곳에서 SAM 변환이 가능합니다. 대표적으로 사용되는 다이얼로그의 버튼 클릭 이벤트 처리에도 사용해 보겠습니다. 사실, 이 리스너도 뷰의 OnClickListener 와 거의 동일한 onClick() 추상메소드를 1개 가지고 있습니다. 다만, 파라미터 개수만 2개로 다를 뿐입니다.

다이얼로그의 버튼은 [긍정의 버튼, 부정의 버튼, 중립의 버튼 ] 3개를 설정할 수 있습니다.

SAM 변환의 과정을 확인하기 위해 버튼 3개의 클릭이벤트 처리를 차례로 익명클래스, 람다표현, SAM 변환으로 처리해 보겠습니다.

 

먼저, 버튼이 클릭되었을 때 다이얼로그가 보이도록 액티비티의 레이아웃 파일 activity_main.xml 에 버튼을 추가하겠습니다. 라디오 그룹 아래에 배치되도록 하겠습니다.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp"
    tools:context=".MainActivity">

    ......

    <Button
        android:id="@+id/btn2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="show AlertDialog"
        android:textAllCaps="false"/>

</LinearLayout>

 

실습의 편의를 의해 별도의 버튼 참조변수 선언 없이 MainActivity의 onCreate() 메소드 안에서 버튼을 찾아와서 곧바로 클릭이벤트를 처리하여 다이얼로그를 만들어 보여주도록 하겠습니다. 

  • 긍정의 버튼 : PositiveButton     -- 익명클래스로 처리
  • 부정의 버튼 : NegativeButton   -- 람다로 처리
  • 중립의 버튼 : NeutralButton      -- SAM conversion 으로 처리
class MainActivity : AppCompatActivity() {

    ...

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        .....

        findViewById<Button>(R.id.btn2).setOnClickListener {

            val builder= AlertDialog.Builder(this)
            builder.setTitle("Dialog")
            builder.setMessage("This is Alert Dialog")
            // 익명클래스로 클릭이벤트 처리
            builder.setPositiveButton("OK", object : DialogInterface.OnClickListener{
                override fun onClick(p0: DialogInterface?, p1: Int) {
                    Toast.makeText(this@MainActivity, "click ok", Toast.LENGTH_SHORT).show()
                }
            })
            // lambda 표현식으로 리스너처리
            builder.setNegativeButton("CANCEL",{po,p1->Toast.makeText(this, "click cancel", Toast.LENGTH_SHORT).show()})
            // SAM conversion 완성 - 위에서 ()도 생략했듯.. 여기서도. ()를 생략하고 {}만 작성하고자 하였으나 "CANCEL"처럼 다른 파라미터가 있으면 그 파라미터는 그대로 ()에 넣고.. {}만 밖으로 이동
            builder.setNeutralButton("OPTION"){po,p1-> Toast.makeText(this, "click option", Toast.LENGTH_SHORT).show()}
            builder.create().show()

        } 
        
    }//onCreate method
    
}//MainActivity class

 

주의해서 보실 부분은 setNeutralButton() 의 SAM 변환 모습입니다. 그동안 소개했던 뷰의 이벤트 처리와 다르게 이 메소드는 파라미터가 리스너 이외에 버튼에 보여지는 글씨를 설정하는 String 파라미터가 존재합니다.

즉, 그동안은 리스너를 설정하는 메소드의 파라미터가 익명클래스만 파라미터로 요구했기에 메소드의 소괄호()를 생략하고 추상메소드의 기능을 실제 구현하는 메소드의 중괄호 { } 만 작성했습니다. 하지만 다이얼로그의 버튼들을 설정하는 메소드는 리스너만 파라미터로 전달하지 않기에 파라미터를 전달하는 소괄호()를 완전 생략할 수 없습니다. 그래서 SAM 변환을 하는 리스너는 소괄호() 밖에 중괄호 { } 를 작성하고 소괄호()에는 버튼에 보여지는 글씨를 설정합니다.

 

즉, 안드로이드 대부분의 리스너는 이런식으로 SAM 변환 코드로 처리가 가능합니다.

 

 

6) SAM conversion은 리스너 뿐이 아니라 안드로이드의 여러 콜백처리에도 사용될 수 있음.

추상메소드가 1개만 있는 함수형 인터페이스를 사용하는 모든 곳에 SAM 변환이 가능합니다.

 

ex) 다른 액티비티를 실행 할때 결과를 받아 돌아오는 ActivityResultLauncher 객체의 콜백 처리

 

먼저, 버튼을 클릭하면 실행된 SecondActivity 를 만들겠습니다.

 

화면은 간단하게 "This is SecondActivity" 라는 글씨를 보여주는 TextView 를 배치하겠습니다.

 

이제. MainActivity 의 레이아웃 파일인 activity_main.xml 에 버튼 하나를 추가하고 이 버튼을 클릭하면 SecondActivity 로 전환하겠습니다. 이때 그냥 전환하는 것이 아니라 결과를 되돌려 받는 ActivityResultLauncher 객체를 사용하고 콜백처리를 익명클래스, 람다표현, SAM 변환 순으로 변경해 보겠습니다.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp"
    tools:context=".MainActivity">

    .....

    <Button
        android:id="@+id/btn3"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="go to SecondActivity"
        android:textAllCaps="false"/>

</LinearLayout>

 

실습의 편의를 위해 별도의 버튼 참조변수 선언 없이 onCreate() 메소드 안에서 버튼을 찾아와 클릭 이벤트를 처리하겠습니다.

findViewById<Button>(R.id.btn3).setOnClickListener {
    //SecondActivity 를 실행하기 위한 인텐트 객체 생성
    val intent: Intent = Intent(this, SecondActivity::class.java)
    
}

 

결과를 받기위해 액티비티를 대신 실행해 주는 일종의 대행사 객체인 ActivityResultLauncher 는 액티비티에 등록(register) 해야 사용할 수 있습니다. 또한 이 등록은 반드시 클래스의 멤버 위치에서 수행해야 합니다. 그래서 onCreate() 메소드 밖에서 객체를 생성하겠습니다.

 

6.1) 익명 클래스로 ActivityResultCallback 객체 처리

class MainActivity : AppCompatActivity() {

    ...

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        .....

        findViewById<Button>(R.id.btn3).setOnClickListener {
            //SecondActivity 를 실행하기 위한 인텐트 객체 생성
            val intent: Intent = Intent(this, SecondActivity::class.java)
            
            //결과를 받아 돌아오는 ActivityResultLauncher 실행객체로 SecondActivity 실행!
            resultLauncher.launch(intent)    
        }
        
    }//onCreate method
    
    //결과를 받아 돌아오는 Activity Result Launcher 실행객체 -- 익명클래스로 ActivityResultCallback 콜백객체 처리
    val resultLauncher: ActivityResultLauncher<Intent> = registerForActivityResult(
        ActivityResultContracts.StartActivityForResult(), object : ActivityResultCallback<ActivityResult> {
            override fun onActivityResult(result: ActivityResult) {
                TODO("Not yet implemented")
            }
        })
    
}//MainActivity class

 

6.2) 일부 SAM 변환하여 ActivityResultCallback 객체 처리

ActivityResultCallback 익명클래스만 SAM 변환으로 처리하였습니다. 콜백 익명클래스의 메소드명과 소괄호()를 모두 생략했습니다.

// SAM 변환을 일부 사용하기
val resultLauncher2: ActivityResultLauncher<Intent> = registerForActivityResult(
    ActivityResultContracts.StartActivityForResult(), ActivityResultCallback {
        if(it?.resultCode== RESULT_OK){
            //.........
        }
    })

 

6.3) 완성된 SAM 변환으로 ActivityResultCallback 객체 처리

registerForActivityResult() 메소드에 콜백객체 외에 다른 파라미터인 ActivityResultContracts 객체는 소괄호() 안에 두고 콜백객체만 SAM 변환의 중괄호{ } 로 분리하여 작성하면 코드의 가독성이 조금더 향상됩니다. 

class MainActivity : AppCompatActivity() {

    ...

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        .....

        findViewById<Button>(R.id.btn3).setOnClickListener {
            //SecondActivity 를 실행하기 위한 인텐트 객체 생성
            val intent: Intent = Intent(this, SecondActivity::class.java)
            
            //결과를 받아 돌아오는 ActivityResultLauncher 실행객체로 SecondActivity 실행!
            //resultLauncher.launch(intent)   
            
            resultLauncher3.launch(intent)
        }
        
    }//onCreate method


    // SAM 변환 완성
    val resultLauncher3: ActivityResultLauncher<Intent> = registerForActivityResult(
        ActivityResultContracts.StartActivityForResult()){
        if(it?.resultCode== RESULT_OK){
            //.........
        }
    }
    
}//MainActivity class

 


 

 

 

반응형
반응형

 

3. 아이템 상세 화면 ItemActivity

리사이클러뷰에서 제공하는 아이템뷰는 보통 사이즈를 작게 만들기에 주요정보만 제공하는 편입니다. 그렇기에 아이템뷰를 클릭했을 때 아이템의 상세정보를 볼 수 있는 별도의 상세화면을 사용합니다. 

이번에는 아이템의 정보를 보다 상세하게 볼 수 있는 상세화면 액티비티를 만들어 보겠습니다. 액티비티의 이름은 ItemActivity 라고 명명하겠습니다.

 

 

 

이번에는 EdgeToEdge UI 를 그대로 두고 구현하겠습니다.

상세화면 구현 4단계
1) MainActivity 에서 아이템뷰 클릭했을 때 ItemActivity 로 전환하는 코드 작성 ( 아이템 정보 전달 및 확인 )
2) 상세화면 레이아웃 xml 파일 작성
3) ItemActivity 에서 아이템 정보를 보여주기
4) 액티비티 전환 효과 구현

 

1) MainActivity 에서 아이템뷰를 클릭했을 때 상세화면(ItemActivity)로 전환하기

 

상세화면으로 전환되는 이벤트는 아이템뷰를 클릭했을 때 입니다. 하여 아이템뷰에 클릭이벤트를 처리하는 리스너를 설정하도록 하겠습니다. 아이템뷰는 보이기에는 MainActivity 화면에 있지만 실제 만들어지는 위치는 MyAdapter 클래스 내부 입니다. 그래서 아이템뷰의 클릭이벤트 처리 역시 MyAdapter 클래스 안에서 설정해야 합니다. 

아이템뷰의 클릭이벤트 처리는 ViewHolder에서 하거나 onBindViewHolder()에서 처리가 가능합니다. 보통 아이템 데이터를 참조하고 있는 onBindViewHolder() 메소드 안에서 처리하는 경우가 더 많습니다. 클릭이벤트는 람다표현보다 간결한 SAM 변환으로 처리를 해보겠습니다. 또한 ItemActivity 상세화면에서 Item 데이터를 보여줘야 하기에 Intent 객체에게 Extra 데이터로 전달하도록 하겠습니다.

//주 생성자 - 클래스명 옆에 constructor 키워드로 추가. 파라미터에 var or val 키워드를 추가하면 멤버변수 면서 매개변수가 됨.
//## RecyclerView.Adapter 상속 ##
class MyAdapter constructor(val context: Context, val items:MutableList<Item>) : RecyclerView.Adapter<MyAdapter.VH>(){

    .........

    //3] 아이템뷰에 Item 데이터 연결해주는 기능 : 현재 만들어야 할 position 번째 Item의 값을 뷰홀더의 멤버인 자식뷰들에 설정
    override fun onBindViewHolder(holder: VH, position: Int) {

        val item= items.get(position)  //현재 아이템뷰에 보여줄 Item 데이터 얻어오기

        holder.tvTitle.setText(item.title)  // 현재 아이템뷰의 제목을 보여주는 텍스트뷰에 [제목] 설정
        holder.tvMsg.text= item.msg         // 현재 아이템뷰의 메세지를 보여주는 텍스트뷰에 [메세지] 설정

        //holder.iv.setImageResource(item.img) // 현재 아이템뷰의 이미지를 보여주는 이미지뷰에 [도시 이미지] 설정
        // 이미지 로딩 라이브러리 Glide 로 이미지 설정
        Glide.with(context).load(item.img).into(holder.iv)
        
        //---------------------------------------------------------

        //SAM conversion 이용하여 이벤트처리
        holder.itemView.setOnClickListener{ v ->

            // ItemActivity 를 실행하기 위한 Intent 객체 생성
            val intent= Intent(context, ItemActivity::class.java)
            
            // 액티비티 전환할 때 Item 데이터 전달을 위해 Intent 에 Extra 데이터 추가
            intent.putExtra("title", item.title)
            intent.putExtra("msg", item.msg)
            intent.putExtra("img", item.img)
            
            // 위 인텐트 객체를 이용하여 ItemActivity 실행
            context.startActivity(intent)

        }
        
    }//onBindViewHolder method....

}//MyAdapter class..

 

상세화면 ItemActivity 에 전달된 Item 데이터(제목, 메세지, 이미지)를 받아 변수에 저장해 놓겠습니다.

class ItemActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContentView(R.layout.activity_item)
        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
        }

        //넘어온 Intent 객체가 이미 이 액티비티의 property로 존재함
        val title= intent.getStringExtra("title")
        val msg= intent.getStringExtra("msg")
        val img= intent.getIntExtra("img", R.drawable.ic_launcher_foreground) //전달받은 이미지가 없다면 기본 앱아이콘 이미지 보여주기
        
    }//onCreate method..
    
}//ItemActivity class....

 

받아온 Item 데이터를 보여주는 뷰들이 필요하겠네요. 글씨 2개(제목, 메세지)와 이미지 1개(도시 이미지)를 보여주는 레이아웃 파일을 만들어 보겠습니다.

 

 

2) 상세화면 레아이웃 xml 파일 만들기

 

Item 데이터(제목, 메세지, 도시 이미지)를 보여주는기 위해 뷰들을 배치하겠습니다. 툴바에는 제목, 이미지뷰에는 도시 이미지를, 메세지는 이미지뷰 아래 텍스트 뷰를 통해 보여주도록 하겠습니다.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".ItemActivity">
    
    <com.google.android.material.appbar.MaterialToolbar
        android:id="@+id/toolbar"
        android:layout_width="match_parent"
        android:layout_height="?attr/actionBarSize"
        android:background="@color/design_default_color_primary"
        app:title="아이템 제목"
        app:titleTextColor="@color/white"
        app:navigationIcon="@drawable/baseline_arrow_back_24"/>

    <ImageView
        android:id="@+id/iv"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:adjustViewBounds="true"
        android:contentDescription="city image"/>

    <TextView
        android:id="@+id/tv"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textColor="@color/black"
        android:padding="8dp"
        android:text="This is message."/>

</LinearLayout>

 

 

3) 상세화면(ItemActivity)에서 아이템 정보 보여주기

 

레이아웃 xml 파일에서 만든 뷰 3개(툴바, 이미지뷰, 텍스트뷰)에 대한 참조변수를 먼저 만들어줍니다. 코틀린은 프로퍼티(멤버변수)를 만들 때 반드시 초기화를 해야 하기에 by lazy 를 이용하여 늦은 초기화로 수행했습니다. 또한 상세화면에서 메인화면으로 다시 돌아가기 위해 제목줄(툴바)에 네비게이션 아이콘 버튼을 통해 뒤로 돌아가도록 하였습니다.

import android.os.Bundle
import android.widget.ImageView
import android.widget.TextView
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.bumptech.glide.Glide
import com.google.android.material.appbar.MaterialToolbar

class ItemActivity : AppCompatActivity() {

    // 뷰들의 참조변수 3개 준비 - 참조변수의 참조값을 변경할 상황이 거의 없기에 val 키워드로 선언.
    // 초기값을 나중에 수행되도록 늦은 초기화 기법으로 by lazy 사용
    val toolbar: MaterialToolbar by lazy { findViewById(R.id.toolbar) }
    val iv: ImageView by lazy { findViewById(R.id.iv) }

    //tv변수를 만들때 명시적으로 자료형이 주어지지 않으면 대입되는 자료형의 값에 따라 추론처리되는데 findViewById()는 어떤 뷰 객체인지 추론이 불가하기에 <>제네릭으로 타입을 지정하면서 대입해줘야 함
    val tv by lazy { findViewById<TextView>(R.id.tv) }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContentView(R.layout.activity_item)
        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
        }

        //넘어온 Intent 객체가 이미 이 액티비티의 property로 존재함
        val title= intent.getStringExtra("title")
        val msg= intent.getStringExtra("msg")
        val img= intent.getIntExtra("img", R.drawable.ic_launcher_foreground) //전달받은 이미지가 없다면 기본 앱아이콘 이미지 보여주기

        // ---- Item 데이터(제목, 메세지, 도시 이미지)를 뷰들에 설정하기 -------------------

        // 툴바에 제목 title 표시
        toolbar.title= title

        // 텍스트뷰에 메세지 msg 표시
        tv.text= msg

        // 이미지뷰에 도시 이미지 img 표시 - 이미지 로드 라이브러리 Glide 사용
        Glide.with(this).load(img).into(iv)
        // ----------------------------------------------------------------------
        
        // 툴바(제목줄)에 뒤로가기 아이콘을 클릭했을 때 이전 화면(MainActivity)로 돌아가기. [ 뒤로가기 디스패처에게 뒤로가기 동작 실행 요청 ]
        toolbar.setNavigationOnClickListener { onBackPressedDispatcher.onBackPressed() }


    }//onCreate method..

}//ItemActivity class....

 

여기까지만 제작해도 충분히 상세화면을 보여주는 목적은 달성한 코드입니다. 다만, 사용자에게 앱의 사용에 대한 몰입감을 주는 UX를 구현하기 위해 액티비티를 전환할 때 전환효과를 주도록 하겠습니다.

 

4) 액티비티 전환할 때 전환 효과 주기

 

전환효과는 메인화면의 작은 아이템뷰에 있던 도시 이미지가 확대되면서 상세화면의 큰 이미지뷰로 연결되는 듯한 효과를 줄 겁니다.

 

전환효과를 적용하려면 이전화면의 특정 뷰와 다음 화면의 특정 뷰에 같은 이름의 '별명'을 지정해주고 '장면전환 SceneTransition'객체를 만들어 액티비티를 전환할 때 옵션으로 지정하면 적용됩니다.

 

4.1 먼저, 아이템뷰가 클릭되었을 때 화면이 전환되므로 아이템뷰가 있는 MyAdapter 클래스 안에서 기존 액티비티 전환코드를 수정하겠습니다. 장면전환 효과 SceneTransitionAnimation 객체를 만들면서 아이템뷰에 있는 도시 사진을 보여주는 이미지뷰에 '별명'을 지정하고 액티비티 옵션객체로 생성하여 인텐트로 ItemActivity를 실행시키는 startActivity()에 두번째 파라미터로 전달하면 전환효과를 적용할 수 있습니다. 주석으로 ~~ 표시 다음에 있는 코드가 새로 추가한 전환효과 코드 입니다. 

별명은 "img" 라는 이름으로 명명하였습니다.

import android.content.Context
import android.content.Intent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.core.app.ActivityOptionsCompat
import androidx.core.util.Pair
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide

//주 생성자 - 클래스명 옆에 constructor 키워드로 추가. 파라미터에 var or val 키워드를 추가하면 멤버변수 면서 매개변수가 됨.
//## RecyclerView.Adapter 상속 ##
class MyAdapter constructor(val context: Context, val items:MutableList<Item>) : RecyclerView.Adapter<MyAdapter.VH>(){

    .........

    //3] 아이템뷰에 Item 데이터 연결해주는 기능 : 현재 만들어야 할 position 번째 Item의 값을 뷰홀더의 멤버인 자식뷰들에 설정
    override fun onBindViewHolder(holder: VH, position: Int) {

        val item= items.get(position)  //현재 아이템뷰에 보여줄 Item 데이터 얻어오기

        holder.tvTitle.setText(item.title)  // 현재 아이템뷰의 제목을 보여주는 텍스트뷰에 [제목] 설정
        holder.tvMsg.text= item.msg         // 현재 아이템뷰의 메세지를 보여주는 텍스트뷰에 [메세지] 설정

        //holder.iv.setImageResource(item.img) // 현재 아이템뷰의 이미지를 보여주는 이미지뷰에 [도시 이미지] 설정
        // 이미지 로딩 라이브러리 Glide 로 이미지 설정
        Glide.with(context).load(item.img).into(holder.iv)

        //---------------------------------------------------------

        //SAM conversion 이용하여 이벤트처리
        holder.itemView.setOnClickListener{ v ->

            // ItemActivity 를 실행하기 위한 Intent 객체 생성
            val intent= Intent(context, ItemActivity::class.java)

            // 액티비티 전환할 때 Item 데이터 전달을 위해 Intent 에 Extra 데이터 추가
            intent.putExtra("title", item.title)
            intent.putExtra("msg", item.msg)
            intent.putExtra("img", item.img)

            // 위 인텐트 객체를 이용하여 ItemActivity 실행
            //context.startActivity(intent)

            //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
            //액티비티의 전환시에 뷰들에 연결 효과주기.. ActivityOptions - SceneTransition   [ Pair()객체 - 전화효과를 줄 뷰에게 별칭을 연결.. 실행될 액티비티에서 연결된 뷰도 같은 별칭을 주면 자동으로 전환효과 보여짐 ] - ex. 당근마켓앱에 구현되어 있음.
            val optionsCompat:ActivityOptionsCompat= ActivityOptionsCompat.makeSceneTransitionAnimation(context as MainActivity, Pair(holder.iv, "img")) //context를 Activity로 형변환 - 코틀린에서 클래스들의 형변환 연산자 as
            context.startActivity(intent, optionsCompat.toBundle())

        }

    }//onBindViewHolder method....

}//MyAdapter class..

 

 

4.2 이제 새로 실행될 상세화면 ItemActivity 에서 전환효과로 연결될 아이템의 도시 사진을 보여주는 큰 이미지뷰에 이전에 지정했던 것과 같은 '별명'을 지정해주면 시스템이 같은 '별명 img' 를 가진 뷰와 이전 화면의 뷰 사이에 전화효과 애니메이션을 자동으로 구현시켜줍니다. 역시 주석 ~~ 표시 다음에 별명을 지정하는 한줄코드를 추가하겠습니다. 

class ItemActivity : AppCompatActivity() {

    // 뷰들의 참조변수 3개 준비 - 참조변수의 참조값을 변경할 상황이 거의 없기에 val 키워드로 선언.
    // 초기값을 나중에 수행되도록 늦은 초기화 기법으로 by lazy 사용
    val toolbar: MaterialToolbar by lazy { findViewById(R.id.toolbar) }
    val iv: ImageView by lazy { findViewById(R.id.iv) }

    //tv변수를 만들때 명시적으로 자료형이 주어지지 않으면 대입되는 자료형의 값에 따라 추론처리되는데 findViewById()는 어떤 뷰 객체인지 추론이 불가하기에 <>제네릭으로 타입을 지정하면서 대입해줘야 함
    val tv by lazy { findViewById<TextView>(R.id.tv) }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContentView(R.layout.activity_item)
        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
        }

        //넘어온 Intent 객체가 이미 이 액티비티의 property로 존재함
        val title= intent.getStringExtra("title")
        val msg= intent.getStringExtra("msg")
        val img= intent.getIntExtra("img", R.drawable.ic_launcher_foreground) //전달받은 이미지가 없다면 기본 앱아이콘 이미지 보여주기

        // ---- Item 데이터(제목, 메세지, 도시 이미지)를 뷰들에 설정하기 -------------------

        // 툴바에 제목 title 표시
        toolbar.title= title

        // 텍스트뷰에 메세지 msg 표시
        tv.text= msg

        // 이미지뷰에 도시 이미지 img 표시 - 이미지 로드 라이브러리 Glide 사용
        Glide.with(this).load(img).into(iv)
        // ----------------------------------------------------------------------

        // 툴바(제목줄)에 뒤로가기 아이콘을 클릭했을 때 이전 화면(MainActivity)로 돌아가기. [ 뒤로가기 디스패처에게 뒤로가기 동작 실행 요청 ]
        toolbar.setNavigationOnClickListener { onBackPressedDispatcher.onBackPressed() }
        
        // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        
        //iv 이미지뷰에 전환효과 주기위에 별칭 부여 [ 이전 액티비티에서 ActivityOptions 에서 Pair()로 설정한 별칭과 같은 명칭
        iv.transitionName = "img"


    }//onCreate method..

}//ItemActivity class....

 

네. iv.transitionName="img"  이 한줄만으로 자동으로 전환효과가 적용됩니다. 참 쉽네요.

 

[ 전환효과 적용 전적용 후 ]

 

 

화면이 전활될 때 이미지가 자연스럽게 연결되는 느낌을 주어 사용자의 몰입감을 유지시켜주는 사용자경험을 제공합니다.

 


 

여기까지 해서 앱을 구현할 때 가장 많이 사용하는 액티비티 전환과 리사이클러뷰를 코틀린으로 만들어 보았습니다.

앞으로의 앱 개발에 대한 소개는 코틀린언어를 통해 소개하도록 하겠습니다. 모든 코틀린 문법을 소개한 것은 아니기에 앞으로 앱개발에 대한 기술을 소개하면서 조금씩 코틀린언어에 대해 익숙해지면서 못다한 내용도 소개하도록 하겠습니다.

반응형
반응형

 

2. 메인 화면 - 리사이클러뷰를 통해 대량의 데이터를 보여주는 화면 MainActivity

대부분의 앱에서 대량의 데이터를 보여주는 용도로 많이 구현하는 RecyclerView 를 코틀린언어를 사용하여 만들어 봄으로서 앞으로의 안드로이드 앱 개발은  코틀린을 이용한 앱개발로 자연스럽게 전환하겠습니다.

 

 

 리사이클러뷰를 사용하여 대량의 데이터를 보여주는 앱들의 UI [ 크몽, 여기어때, 오늘의 집 ]

리사이클러뷰를 사용하는 앱들의 UI

 

 

이번 글은 리사이클러뷰에 대한 학습이라기 보다 코틀린으로 구현하는 리사이클러뷰에 대한 내용이기에 위의 앱들과 같은 모양의 UI 구현은 너무 오래걸릴 듯 화여 최대한 간결하게 Image 1개, Text 2개를 가진 아이템뷰만 간단하게 만들어보겠습니다.

 

리사이클러뷰를 이용한 대량의 데이터를 보여주는 화면을 만들려면 여러 단계와 파일들을 만들어야 합니다. 그래서 초보자들의 경우에는 관려예제를 보고 따라 만드는 것 조차 쉽게 수행하지 못하는 경우가 많습니다. 그래서 필자의 경우에는 리사이클러뷰를 구현할 때 나름의 순서를 기준으로 하여 구현하는 편입니다. 정답은 아니지만 제가 선호하는 순서를 소개하고 그대로 만들어 보겠습니다. 혹시 리사이클러뷰 구현에 어려움을 겪고 있으시다면 이 방법을 써보시길 권해드립니다.

리사클러뷰 구현 단계
1) 액티비티 레이아웃 xml 파일에 RecyclerView 추가 및 액티비티에서 참조변수 선언
2) 대량의 데이터 준비( List , Data class )
3) 아이템뷰 1개의 레이아웃 xml 파일 작성
4) 대량의 데이터(List) 개수만큼 아이템뷰(아이템뷰 1개의 레이아웃 모양) 객체를 생성하는 아답터 클래스 설계
5) 아답터 객체 생성 및 리사이클러뷰에 아답터로 설정하여 완성

 

 

1) RecyclerView 추가 및 참조변수 만들기

 

레이아웃 파일은 간단하게 리사이클러뷰 1개만 배치하도록 하겠습니다.

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:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

 

기본으로 만들어져 있는 MainActivity 클래스 파일을 보겠습니다. 역시 컴포즈 개발방법의 등장과 함께 도입된 화면 전체를 사용하는 Edge To Edge 와 Navigation Bar 사이즈를 얻어와서 패딩을 주는 기능이 작성되어 있습니다.

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()    //화면 전체를 사용하는 기능
        setContentView(R.layout.activity_main)
        
        //Navigation Bar 의 사이즈만큼 액티비티 패딩 설정.
        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
        }
    }
}

 

MainActivity 역시 이전 IntroActivity 와 마찬가지로 리사이클러뷰의 사용에만 집중하기 위해 불필요한 EdgeToEdge 는 제거하고 시작하겠습니다. 

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)        
        setContentView(R.layout.activity_main) //화면에 보여줄 뷰로 레이아웃 xml 파일 설정
        
    }//onCreate method...
}//MainActivity class.....

 

이제 레이아웃 xml 파일에서 추가한 리사이클러뷰를 제어하기 위한 참조변수를 만들겠습니다. 이전에는 var 키워드를 사용해보았습니다. 

하지만 뷰 참조변수들은 한번 뷰객체를 참조하면 다른 뷰객체로 바꾸어 참조하는 경우가 거의 없기에 var보다 val을 사용해도 됩니다. 저는 이런 이유로 값변경이 불가능한 val 변수로 만드는 것을 선호합니다.
val 변수도 역시 초기화를 하지 않으면 에러입니다. 하지만 RecyclerView의 객체 참조값을 지금 초기화 할 수는 없습니다. 이전에서 소개했듯이 onCreate() 에서 findViewById()를 해야 하기에 나중에 해야 합니다. 그렇다고 nullable 변수로 만들면 null 값을 다른 참조값을 바꿀 수 없기에 사용할 수 없습니다. 그래서 늦은초기화 문법을 사용하겠습니다.

단, lateinit 은 var에만 사용할 수 있기에 by lazy 지연 초기화 문법으로 초기화를 진행하겠습니다. lazy 중괄호 { } 안의 작성 내용을 참조변수가 처음 사용될 때까지 좀 게으르게(lazy) 지연하여 초기화를 수행하기에 문제없이 RecyclerView 객체의 생성 이후 참조값을 얻어올 수 있습니다.

class MainActivity : AppCompatActivity() {

    // 뷰 참조변수들은 한번 뷰객체를 참조하면 다른 뷰객체로 바꾸어 참조하는 경우가 거의 없기에 var보다 val을 선호함
    // 이때, 초기화 안하면 에러 - 하지만 recycler의 객체 참조값을 지금 초기화 할 수 없음 [ onCreate() 에서 findViewById()를 해야 하기에..]
    // 그래서 늦은초기화 사용[ lateinit 은 var에만 사용할 수 있기에 by lazy 지연초기화 사용]
    val recycler: RecyclerView by lazy { findViewById(R.id.recycler) }
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

    }//onCreate method...
}//MainActivity class.....

 

보다시피 한 줄에 참조변수 선언과 findViewById()를 작성할 수 있어서 lateinit var 늦은 초기화 보다 코드의 가독성이 좋아서 저는 더 선호하는 편입니다. 개발자 마다 뷰의 초기화 참조변수를 만드는 방법은 약간씩 차이가 있으니 어떤 것이 정답이라는 생각보다는 본인이 편하고 좋다고 생각하시는 방법으로 뷰 참조변수를 만드시길 바랍니다.


 

 

앞서 언급했듯이 가급적 간결하게 아이템뷰를 구성하고자 합니다. 여행지 정보를 보여주는 앱이라고 가정하고 아래 모양처럼 구성하고자 합니다. 아이템뷰 당 이미지 1개, 제목글씨 1개, 메세지 글씨 1개로 구성하고자 합니다.

 

 

2) 리사이클러뷰가 보여줄 대량의 데이터 준비

 

※ 여행지 이미지 파일 3개 : newyork.jpg, paris.jpg, sydney.jpg     

newyork.jpg
0.23MB
paris.jpg
0.19MB
sydney.jpg
0.10MB

 

실제 앱들의 이미지는 서버에 있는 이미지를 불어와서 보여주기에 프로젝트 폴더 안에 이미지파일이 존재하지 않지만 실습의 편의를 위해 위 3개의 사진파일을 res 폴더 > drawable 폴더안에 넣어놓고 리소스 ID 로 불러와서 보여주도록 하겠습니다.

 

 

다음으로 아이템뷰가 보여줄 아이템 1개( 이미지, 제목, 메세지 ) 데이터를 저장하기 위한 데이터 클래스를 하나 만들겠습니다. 클래스명은 단순하게 Item 이라고 명명하겠습니다. 

 

코틀린은 클래스 중에서 data class 라는 것이 있습니다. class 앞에 data 키워드를 추가하면 데이터 클래스로 만들어집니다. 이름 그대로 데이터를 저장하는 용도의 클래스를 만들 때 사용합니다. 

데이터 클래스 : 데이터만 저장하는 목적은 클래스
- 일반 class와 다르게 자동으로 equals()할때 객체 참조주소의 비교가 아니라 주생성자의 멤버변수를 비교해주는 특별한 클래스 
- 별도의 기능 메소드를 가지고 있지 않는 클래스 이기에 클래스의 설계를 위한 중괄호{} 영역을 작성하지 않음.

 

Android Studio data class 만들기

//데이터를 저장하는 용도의 클래스 ( 제목, 메세지, 이미지 파일 리소스 경로)
data class Item constructor(var title:String, var msg:String, var img:Int) //- 특별한 기능메소드를 작성할 필요없기에 보통은 {}도 생략함.

 

대량의 데이터는 보통 서버나 DB에서 불어와야 하지만 지금은 리사이클러뷰 구현에 집중하기 위해 테스트용으로 더미 데이터를 직접 작성하여 추가하도록 하겠습니다. 

리사이클러뷰를 보여주는 MainActivity에서 Item 데이터 객체를 여러개 보관할 리스트를 멤버변수로 만들고 더미데이터를 추가하도록 하겠습니다.

class MainActivity : AppCompatActivity() {

    // 리사이클러뷰 참조변수 - 늦은 초기화
    val recycler: RecyclerView by lazy { findViewById(R.id.recycler) }

    //대량의 데이터 property[속성:멤버변수]
    var items= mutableListOf<Item>() //Java의 ArrayList<Item>와 비슷하게 동작
    //var items= arrayListOf<Item>()  //이렇게 ArrayList 객체를 만들어도 됨

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        //대량의 데이터들 추가 [테스트목적]
        items.add( Item("NEW YORK","Hello. Kotlin", R.drawable.newyork) )
        items.add( Item("PARIS","Nice to meet you", R.drawable.paris) )
        items.add( Item("SYDNEY","Have a good day", R.drawable.sydney) )
        items.add( Item("뉴욕","Do you have fun", R.drawable.newyork) )
        items.add( Item("파리","Nice to meet you", R.drawable.paris) )
        items.add( Item("시드니","Have a good day", R.drawable.sydney) )
        items.add( Item("new york","Do you have fun", R.drawable.newyork) )
        items.add( Item("paris","Nice to meet you", R.drawable.paris) )
        items.add( Item("sydney","Have a good day", R.drawable.sydney) )
        
    }
    
}

 

 

3) Item 데이터를 보여줄 아이템뷰의 1개의 모양 디자인을 위한 레이아웃 파일 만들기

 

Item 객체 1개의 데이터( 제목, 메세지, 이미지)를 보여줄 아이템뷰 1개의 모양 디자인을 위한 레이아웃 xml 파일을 만들어 보겠습니다.

 

[ 디자인 스케치 - 도형 ]             |             [ 구현된 아이템뷰 1개의 실제 모양 스크린샷 ]

 

recycler_item.xml
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="160dp"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    app:cardCornerRadius="8dp"
    app:cardElevation="4dp"
    android:layout_margin="12dp">

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <ImageView
            android:id="@+id/iv"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:src="@drawable/newyork"
            android:scaleType="centerCrop"/>
        <TextView
            android:id="@+id/tv_title"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="TITLE"
            android:textStyle="bold"
            android:textSize="40sp"
            android:textColor="@color/white"
            android:layout_alignParentRight="true"
            android:layout_margin="16dp"/>
        <TextView
            android:id="@+id/tv_msg"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="This is message"
            android:textColor="@color/white"
            android:textStyle="bold"
            android:textSize="18sp"
            android:layout_alignParentRight="true"
            android:layout_alignParentBottom="true"
            android:layout_margin="16dp"/>

    </RelativeLayout>

</com.google.android.material.card.MaterialCardView>

 

 

4) 리사이클러뷰가 보여줄 아이템 뷰를 리스트 개수만큼 객체로 만들어서 제공하는 아답터 객체 생성

 

이제 테스트 목적으로 직접 추가한 9개의 Item 데이터를 사용자가 볼 수 있는 아이템뷰 객체로 만들어주는 아답터 클래스를 설계해 보겠습니다. 아이템 1개의 모양을 설계한 recycler_item.xml 파일의 레이아웃 모양으로 아이템 뷰를 리스트 개수만큼 만들어서 리사이클러뷰에 제공하도록 구현하겠습니다.

리사이클러뷰를 구현할 때 가장 핵심적인 역할을 수행하는 클래스로서 RecyclerView.Adapter 클래스를 상속받아 구현합니다. 또한 상속을 받을 때 아이템뷰의 자식뷰들의 참조값을 저장하는 ViewHolder 클래스를 <>제네릭 타입으로 지정하여 만듭니다.

 

아탑터 클래스의 이름은 내가 만든 아답터라는 의미로 MyAdapter 로 명명해 보겠습니다.

 

MyAdapter 클래스를 만들때 2개의 프로퍼티(멤버변수)를 만들겠습니다.

- context : 아답터 클래스 안에서 운영체제의 기능들을 사용해야 하는 경우가 많기에 보통 Context 를 멤버변수(프로퍼티)로 준비함.

- items : 대량의 데이터를 아이템뷰객체로 만들어 주기에 대량의 데이터인 items:MutableList 멤버변수(프로퍼티)로 준비함.

 

코틀린은 멤버변수를 만들고 생성할 때 초기화를 시키고자 한다면 주 생성자(Primary Constructor)를 사용할 것을 권장합니다. 주 생성자의 파라미터를 만들때 var or val 키워드를 추가하면 멤버변수면서 매개변수가 되기에 코드가 훨씬 간결해 집니다.

MyAdapter.kt
import android.content.Context

//주 생성자 - 클래스명 옆에 constructor 키워드로 추가. 파라미터에 var or val 키워드를 추가하면 멤버변수 면서 매개변수가 됨.
class MyAdapter constructor(val context: Context, val items:MutableList<Item>){
}

 

이제 RecyclerView를 보여주는 MainActivity 에서 MyAdapter 객체를 생성할 때 context와 대량의 Item 데이터인 items를 생성자 파라미터에 전달해 주면 됩니다.

 

앞에서 소개했듯이 아답터는 아이템뷰에 데이터인 Item의 값들을 설정해 주어야 하기에 아이템뷰의 자식뷰들을 참조하여 저장하는 뷰홀더클래스를 이너클래스로 만들어 사용합니다. 코틀린은 자바와 다르게 이너클래스를 만들 때 inner class 로 만들어야 온전하게 이너클래스가 됩니다. 또한 이 뷰홀더 클래스는 RecyclerView.ViewHolder 를 상속하여 만들어야 합니다. 

그럼. MyAdater 클래스의 이너 클래스로 뷰홀더 클래스를 만들어 보겠습니다. 이름은 뷰홀더라는 의미로 VH 라고 명명하겠습니다.

import android.content.Context
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView

//주 생성자 - 클래스명 옆에 constructor 키워드로 추가. 파라미터에 var or val 키워드를 추가하면 멤버변수 면서 매개변수가 됨.
class MyAdapter constructor(val context: Context, val items:MutableList<Item>){

    //아이템뷰 1개의 자식뷰들을 참조하는 참조변수를 저장하는 뷰홀더 클래스 (이너클래스) - 아이템뷰를 생성자 파라미터로 받아서 사용
    inner class VH constructor(itemView: View) : RecyclerView.ViewHolder(itemView){
        // 아이템뷰의 자식뷰( 제목을 보여주는 텍스트뷰, 메세지를 보여주는 텍스트뷰, 이미지를 보여주는 이미지뷰 )
        // by lazy : 지연초기화를 이용하여 자식뷰들 참조
        val tvTitle: TextView by lazy { itemView.findViewById(R.id.tv_title) }
        val tvMsg: TextView by lazy { itemView.findViewById(R.id.tv_msg) }
        val iv: ImageView by lazy { itemView.findViewById(R.id.iv) }
    }
    
}

 

이제 리사이클러뷰의 아답터로서 반드시 구현해야 할 메소드 3개를 만들기 위해 MyAdapter에 RecyclerView.Adapter 를 상속해 주도록 하겠습니다. 이때 이너클래스로 설계한 VH 클래스를 제네릭으로 지정해 주면 뷰홀더 클래스로 활용할 수 있게 됩니다.

RecyclerView.Adapter 클래스는 3개의 추상메소드를 가지고 있어서 반드시 구현해야 합니다. 이를 통해 개발자가 아답터의 필수 구현기능 3개를 실수없이 제작할 수 있도록 강제하게 됩니다.

import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView

//주 생성자 - 클래스명 옆에 constructor 키워드로 추가. 파라미터에 var or val 키워드를 추가하면 멤버변수 면서 매개변수가 됨. 
//## RecyclerView.Adapter 상속 ##
class MyAdapter constructor(val context: Context, val items:MutableList<Item>) : RecyclerView.Adapter<MyAdapter.VH>(){

    //아이템뷰 1개의 자식뷰들을 참조하는 참조변수를 저장하는 뷰홀더 클래스 (이너클래스) - 아이템뷰를 생성자 파라미터로 받아서 사용
    inner class VH constructor(itemView: View) : RecyclerView.ViewHolder(itemView){
        // 아이템뷰의 자식뷰( 제목을 보여주는 텍스트뷰, 메세지를 보여주는 텍스트뷰, 이미지를 보여주는 이미지뷰 )
        // by lazy : 지연초기화를 이용하여 자식뷰들 참조
        val tvTitle: TextView by lazy { itemView.findViewById(R.id.tv_title) }
        val tvMsg: TextView by lazy { itemView.findViewById(R.id.tv_msg) }
        val iv: ImageView by lazy { itemView.findViewById(R.id.iv) }
    }

    //반드시 구현해야 할 추상 메소드 3개. ------------------------------------------------------------------

    //1] 아이템뷰 생성 기능 : recycler_item.xml 레이아웃 모양의 아이템뷰 객체를 생성하여 뷰홀더에 전달하여 리턴하는 메소드
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
        TODO("Not yet implemented")
    }

    //2] 아답터가 만들 아이템뷰의 총 개수를 리턴하는 기능 : 대량의 데이터인 items 의 개수.
    override fun getItemCount(): Int {
        TODO("Not yet implemented")
    }

    //3] 아이템뷰에 Item 데이터 연결해주는 기능 : 현재 만들어야 할 position 번째 Item의 값을 뷰홀더의 멤버인 자식뷰들에 설정
    override fun onBindViewHolder(holder: VH, position: Int) {
        TODO("Not yet implemented")
    }

}

 

이제 필수 기능 메소드 3개의 코드를 차례로 작성해 보겠습니다. 이미 자바언어를 통해 리사이클러뷰의 아답터를 제작해 보았다는 것을 전제로 소개하고 있기에 코드에 대한 소개는 간략하게 주석으로 소개하겠습니다.

 

1] 아이템뷰 생성 기능 : recycler_item.xml 레이아웃 모양의 아이템뷰 객체를 생성하여 뷰홀더에 전달하여 리턴하는 메소드

//1] 아이템뷰 생성 기능 : recycler_item.xml 레이아웃 모양의 아이템뷰 객체를 생성하여 뷰홀더에 전달하여 리턴하는 메소드
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
    val inflater= LayoutInflater.from(context)  //xml 레이아웃 파일 모양으로 뷰 객체를 생성해 주는 객체 소환
    val itemView= inflater.inflate(R.layout.recycler_item, parent, false) //아이템뷰 객체 생성
    val holder= VH(itemView) //아이템뷰의 자식뷰들을 참조해주는 뷰홀더 객체 생성
    return holder //뷰홀더 객체 리턴
}

 

2] 아답터가 만들 아이템뷰의 총 개수를 리턴하는 기능 : 대량의 데이터인 items 의 개수

//2] 아답터가 만들 아이템뷰의 총 개수를 리턴하는 기능 : 대량의 데이터인 items 의 개수.
override fun getItemCount(): Int {
    return items.size  //멤버변수인 대량의 Item 리스트의 총 개수 리턴.
}

 

3] 아이템뷰에 Item 데이터 연결해주는 기능 : 현재 만들어야 할 position 번째 Item의 값을 뷰홀더의 멤버인 자식뷰들에 설정

//3] 아이템뷰에 Item 데이터 연결해주는 기능 : 현재 만들어야 할 position 번째 Item의 값을 뷰홀더의 멤버인 자식뷰들에 설정
override fun onBindViewHolder(holder: VH, position: Int) {
    
    val item= items.get(position)  //현재 아이템뷰에 보여줄 Item 데이터 얻어오기

    holder.tvTitle.setText(item.title)  // 현재 아이템뷰의 제목을 보여주는 텍스트뷰에 [제목] 설정
    holder.tvMsg.text= item.msg         // 현재 아이템뷰의 메세지를 보여주는 텍스트뷰에 [메세지] 설정

    holder.iv.setImageResource(item.img) // 현재 아이템뷰의 이미지를 보여주는 이미지뷰에 [도시 이미지] 설정
 
}

 

* 참고로 이미지뷰에 이미지를 설정할 때는 기본 기능인 setImageResource()보다는 이미지로딩 용 외부 라이브러리 사용을 권해 드립니다. 기본 기능과 다르게 이미지의 용량이 아주 클때 자동으로 scale 을 해주며, gif 와 같은 동적 이미지와 네트워크 이미지 로딩도 지원해 줍니다. 그래서 위 코드에서 마지막 holder.iv 에 이미지를 불러와 보여주는 코드는 Glide 라는 이미지로딩 외부 라이브러리를 사용하도록 수정해 보겠습니다. Glide 라이브러리 추가는 스스로 하실 수 있다고 보고 설명을 생략하도록 하겠습니다.

 

build.gradle.kts(Module : app)

 

Glide 를 추가했으니 이미지 설정 코드를 수정해 보겠습니다.

//3] 아이템뷰에 Item 데이터 연결해주는 기능 : 현재 만들어야 할 position 번째 Item의 값을 뷰홀더의 멤버인 자식뷰들에 설정
override fun onBindViewHolder(holder: VH, position: Int) {

    val item= items.get(position)  //현재 아이템뷰에 보여줄 Item 데이터 얻어오기

    holder.tvTitle.setText(item.title)  // 현재 아이템뷰의 제목을 보여주는 텍스트뷰에 [제목] 설정
    holder.tvMsg.text= item.msg         // 현재 아이템뷰의 메세지를 보여주는 텍스트뷰에 [메세지] 설정

    //holder.iv.setImageResource(item.img) // 현재 아이템뷰의 이미지를 보여주는 이미지뷰에 [도시 이미지] 설정
    // 이미지 로딩 라이브러리 Glide 로 이미지 설정
    Glide.with(context).load(item.img).into(holder.iv)
}

 

완성된 MyAdapter 클래스의 전체 코드 입니다.

import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide

//주 생성자 - 클래스명 옆에 constructor 키워드로 추가. 파라미터에 var or val 키워드를 추가하면 멤버변수 면서 매개변수가 됨.
//## RecyclerView.Adapter 상속 ##
class MyAdapter constructor(val context: Context, val items:MutableList<Item>) : RecyclerView.Adapter<MyAdapter.VH>(){

    //아이템뷰 1개의 자식뷰들을 참조하는 참조변수를 저장하는 뷰홀더 클래스 (이너클래스) - 아이템뷰를 생성자 파라미터로 받아서 사용
    inner class VH constructor(itemView: View) : RecyclerView.ViewHolder(itemView){
        // 아이템뷰의 자식뷰( 제목을 보여주는 텍스트뷰, 메세지를 보여주는 텍스트뷰, 이미지를 보여주는 이미지뷰 )
        // by lazy : 지연초기화를 이용하여 자식뷰들 참조
        val tvTitle: TextView by lazy { itemView.findViewById(R.id.tv_title) }
        val tvMsg: TextView by lazy { itemView.findViewById(R.id.tv_msg) }
        val iv: ImageView by lazy { itemView.findViewById(R.id.iv) }
    }

    //반드시 구현해야 할 추상 메소드 3개. ------------------------------------------------------------------

    //1] 아이템뷰 생성 기능 : recycler_item.xml 레이아웃 모양의 아이템뷰 객체를 생성하여 뷰홀더에 전달하여 리턴하는 메소드
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
        val inflater= LayoutInflater.from(context)  //xml 레이아웃 파일 모양으로 뷰 객체를 생성해 주는 객체 소환
        val itemView= inflater.inflate(R.layout.recycler_item, parent, false) //아이템뷰 객체 생성
        val holder= VH(itemView) //아이템뷰의 자식뷰들을 참조해주는 뷰홀더 객체 생성
        return holder //뷰홀더 객체 리턴
    }

    //2] 아답터가 만들 아이템뷰의 총 개수를 리턴하는 기능 : 대량의 데이터인 items 의 개수.
    override fun getItemCount(): Int {
        return items.size  //멤버변수인 대량의 Item 리스트의 총 개수 리턴.
    }

    //3] 아이템뷰에 Item 데이터 연결해주는 기능 : 현재 만들어야 할 position 번째 Item의 값을 뷰홀더의 멤버인 자식뷰들에 설정
    override fun onBindViewHolder(holder: VH, position: Int) {

        val item= items.get(position)  //현재 아이템뷰에 보여줄 Item 데이터 얻어오기

        holder.tvTitle.setText(item.title)  // 현재 아이템뷰의 제목을 보여주는 텍스트뷰에 [제목] 설정
        holder.tvMsg.text= item.msg         // 현재 아이템뷰의 메세지를 보여주는 텍스트뷰에 [메세지] 설정

        //holder.iv.setImageResource(item.img) // 현재 아이템뷰의 이미지를 보여주는 이미지뷰에 [도시 이미지] 설정
        // 이미지 로딩 라이브러리 Glide 로 이미지 설정
        Glide.with(context).load(item.img).into(holder.iv)
    }

}

 

 

5) 아답터 객체 생성 및 리사이클러뷰에 아답터로 설정하여 완성하기

 

이제 리사이클러뷰를 보여주는 MainActivity 에서 MyAdapter 클래스를 객체로 생성하여 리사이클러뷰에 설정해 주겠습니다.

class MainActivity : AppCompatActivity() {

    // 뷰 참조변수들은 한번 뷰객체를 참조하면 다른 뷰객체로 바꾸어 참조하는 경우가 거의 없기에 var보다 val을 선호함
    // 이때, 초기화 안하면 에러 - 하지만 recycler의 객체 참조값을 지금 초기화 할 수 없음 [ onCreate() 에서 findViewById()를 해야 하기에..]
    // 그래서 늦은초기화 사용[ lateinit 은 var에만 사용할 수 있기에 by lazy 지연초기화 사용]
    val recycler: RecyclerView by lazy { findViewById(R.id.recycler) }

    //대량의 데이터 property[속성:멤버변수]
    var items= mutableListOf<Item>() //Java의 ArrayList<Item>와 비슷하게 동작
    //var items= arrayListOf<Item>()  //이렇게 ArrayList 객체를 만들어도 됨

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        //대량의 데이터들 추가 [테스트목적]
        items.add( Item("NEW YORK","Hello. Kotlin", R.drawable.newyork) )
        items.add( Item("PARIS","Nice to meet you", R.drawable.paris) )
        items.add( Item("SYDNEY","Have a good day", R.drawable.sydney) )
        items.add( Item("뉴욕","Do you have fun", R.drawable.newyork) )
        items.add( Item("파리","Nice to meet you", R.drawable.paris) )
        items.add( Item("시드니","Have a good day", R.drawable.sydney) )
        items.add( Item("new york","Do you have fun", R.drawable.newyork) )
        items.add( Item("paris","Nice to meet you", R.drawable.paris) )
        items.add( Item("sydney","Have a good day", R.drawable.sydney) )

        // ----------------------------------------------------------------------------
        
        //리사이클러뷰안에 이미 아답터 프로퍼티(멤버변수)가 있어서 아답터객체를 대입해 주면 됨
        recycler.adapter= MyAdapter(this, items)

    }//onCreate method...
}//MainActivity class.....

 

마지막으로, 리사이클러뷰가 아이템뷰를 세로 목록형으로 나열되도록 레이아웃 매니저를 LinearLayoutManager 로 설정해 주겠습니다.

class MainActivity : AppCompatActivity() {

    // 뷰 참조변수들은 한번 뷰객체를 참조하면 다른 뷰객체로 바꾸어 참조하는 경우가 거의 없기에 var보다 val을 선호함
    // 이때, 초기화 안하면 에러 - 하지만 recycler의 객체 참조값을 지금 초기화 할 수 없음 [ onCreate() 에서 findViewById()를 해야 하기에..]
    // 그래서 늦은초기화 사용[ lateinit 은 var에만 사용할 수 있기에 by lazy 지연초기화 사용]
    val recycler: RecyclerView by lazy { findViewById(R.id.recycler) }

    //대량의 데이터 property[속성:멤버변수]
    var items= mutableListOf<Item>() //Java의 ArrayList<Item>와 비슷하게 동작
    //var items= arrayListOf<Item>()  //이렇게 ArrayList 객체를 만들어도 됨

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        //대량의 데이터들 추가 [테스트목적]
        items.add( Item("NEW YORK","Hello. Kotlin", R.drawable.newyork) )
        items.add( Item("PARIS","Nice to meet you", R.drawable.paris) )
        items.add( Item("SYDNEY","Have a good day", R.drawable.sydney) )
        items.add( Item("뉴욕","Do you have fun", R.drawable.newyork) )
        items.add( Item("파리","Nice to meet you", R.drawable.paris) )
        items.add( Item("시드니","Have a good day", R.drawable.sydney) )
        items.add( Item("new york","Do you have fun", R.drawable.newyork) )
        items.add( Item("paris","Nice to meet you", R.drawable.paris) )
        items.add( Item("sydney","Have a good day", R.drawable.sydney) )

        // ----------------------------------------------------------------------------

        //리사이클러뷰안에 이미 아답터 프로퍼티(멤버변수)가 있어서 아답터객체를 대입해 주면 됨
        recycler.adapter= MyAdapter(this, items)

        //리사이클러뷰에 레이아웃매니저 붙이기
        recycler.layoutManager= LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)

    }//onCreate method...
}//MainActivity class.....

 


 

이렇게 해서 리사이클러뷰를 보여주는 코드를 코틀린으로 구현해보는 작업을 완료했습니다.

잘 구현되었는지 확인해 볼까요?

 

 

 

이번 글은 좀 길었네요.

다음글에서 마지막으로 아이템뷰를 클릭하였을 때 Item 상세 화면 액티비티로 전환하도록 만들겠습니다.

반응형
반응형

이제까지 코틀린 언어의 문법을 대략적으로 알아보았으니 실제 앱 개발을 해봐야 겠죠.

안드로이드 앱 개발에서 가장 기본적인 기술인 액티비티 화면 전환과 리사이클러뷰를 통한 대량의 데이터를 보여주는 앱을 만들어 보겠습니다. 아마 이 예제를 만들어보면 코틀린을 이용한 앱 개발이 어떤 건지 대략적인 감이 올 겁니다. 더불어 자바언어로 개발했을 때에 비해 얼마나 효과적인지 느껴보실 수 있을 겁니다.

액티비티 전환 방법과 리사이클러뷰에 대한 기술 소개는 다른 글에서 소개했으니 이번에는 코틀린언어의 적용 모습에 포커스를 두고 살펴보시길 바랍니다. 


실습을 위한 프로젝트를 생성하겠습니다. 

 

Project Template : Empty Views Activity

 

Name : KotlinRecyclerViewApp

Language : Kotlin

Minimum SDK : API 26

Build configuration language : Kotlin DSL


 

 

대부분의 앱은 1개 이상의 화면을 보유하고 있는 만큼 화면을 담당하는 액티비티 전환을 코틀린으로 구현해보기 위해 화면 구성은 총 3개를 만들어 볼 예정입니다.

 

화면(Activity) 3개

1. IntroActivity  : 진입 화면 - 화면이동 버튼

2. MainActivity : 리사이클러뷰를 보여주는 화면

3. ItemActivity : 아이템 상세화면 ( 리사이클러뷰의 아이템뷰 클릭시에 이동될 아이템 상세화면 ~ 화면전환 애니메이션 적용 )

 

 

♣ 새로운 Activity 생성 [ 코틀린 클래스 파일.kt, 레이아웃 파일.xml, AndroidManifest.xml 에 등록 ]

New > Activity > Empty Views Activity

 

이렇게 안드로이드 스튜디오의 Activity 생성 메뉴를 통해 새로운 액티비티를 생성하면 Activity를 상속한 코틀린 클래스 파일과 이 액티비티에서 보여줄 화면을 구성하는 레이아웃 xml 파일을 자동으로 생성해 줍니다. 또한 앱의 구성요소(Component)를 등록하는 앱의 시작파일인 AndroidManifest.xml 파일에 등록까지 해줍니다.

 

 

1. 인트로 화면 - 진입화면

앱의 진입 화면으로 사용하는 액티비티 이기에 생성할 때 [ Launcher Activity ] 체크박스를 체크해 주시기 바랍니다.

 

런처 액티비티로 생성하였기에 AndroidManifest.xml 에 등록될 때 <Intent-Filter> 가 추가되어 있습니다. 사실 이 상태 그대로 코드를 작성해도 되지만 기존에 런처 액티비티였던 MainActivity는 더이상 진입화면으로 사용하지 않을 것이기에 인텐트필터를 제거하고 외부에서 호출할 수 없도록 exported 속성을 false 로 변경하겠습니다.

변경 전 변경 후

<activity
    android:name=".MainActivity"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />

        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

<activity
    android:name=".MainActivity"
    android:exported="false">
</activity>

 

 

먼저, 인트로 화면의 레이아웃 xml 파일을 작성해 보겠습니다. 실제 앱을 구현하는게 목적이 아니고 단지, 액티비티 전환을 코틀린으로 구현하는 것이 목적인 만큼 별도의 디자인 없이 Button 만 배치 하여 MainActivity로 전환할 수 있도록 하겠습니다.

버튼의 클릭이벤트에 반응하는 리스너 설정은 우선 익명클래스로 구현해 보겠습니다.

activity_intro.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".IntroActivity">

    <!-- 익명클래스로 리스너 처리   -->
    <Button
        android:id="@+id/btn"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="go to Main"
        android:layout_centerInParent="true"/>

</RelativeLayout>

 

이제 액티비티 클래스 코드를 작성해 보겠습니다.

우선 필요한 코드에 집중하기 위해 불필요한 코드는 제거하고 시작하겠습니다. 안드로이드 컴포즈가 등장하면서 Edge-To-Edge UI 를 기본적으로 구현된 상태로 액티비티가 만들어집니다.

 

♣ Edge-To-Edge UI 

액티비티는 기본적으로 디스플레이 화면 전체를 사용하지 않습니다. 상태표시줄(Status bar)과 네비게이션바(Navigation bar) 영역을 제외한 부분만을 사용합니다. 예전에는 화면전체를 사용하려면 WindowManager 를 통해 Flag 설정을 직접 해야 했습니다. 하지만 안드로이드 버전이 변경되면 기존 Flag 설정으로 적용되지 않는 등의 문제가 있어 개발자들을 조금 불편하게 했습니다. 

구글은 컴포즈 개발방법을 도입하면서 UI 적으로 화면 전체를 사용하게 함으로서 사용자에게 몰입감을 주고자 화면을 상단 끝(Edge)에서 하단 끝(Edge) 까지 사용하는 것을 권장합니다. 그래서 화면 전체 사용을 편하게 적용하게 하기 위해 enableEdgeToEdge() 기능을 제공하며 기본 보일러플레이트 코드(미리 작성되어 있는 코드)로 추가하였습니다. Android 15버전 부터는 모든 앱에 기본적으로 적용된다고 합니다.

 

class IntroActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()    // 화면전체 사용 ON
        setContentView(R.layout.activity_intro)  // 액티비티가 보여줄 뷰를 설정
        
        //Edge-To-Edge 일때 하단 네비게시션바 사이즈 만큼 패딩 적용
        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
        }
    }
}

 

enableEdgeToEdge() 기능을 사용하면 상태표시줄 뿐만 아니라 하단의 네비게이션바 영역까지 액티비티가 확장됩니다. 하지만 네비게이션바는 시스템바 이기에 없어진 것은 아니고 액티비티 위에 오버레이 되어 그려집니다. 그러다 보니 액티비티의 가장 아래쪽은 네비게이션바에 의해 콘텐츠가 일부 가려지는 문제가 발생할 수 있습니다. 하여 네비게이션바 사이즈를 얻어와서 그 만큼 Window 의 안쪽 패딩을 적용하는 코드까지 추가되었습니다. 네비게이션 바의 종류(제스처, 2버튼, 3버튼)나 디바이스의 종류에 따라 사이즈가 다르기에 리스너를 통해 시스템바의 사이즈를 동적으로 얻어와서 패딩을 적용하는 코드입니다. 덕분에 코드가 지저분해 보이기도 합니다. 

 

이번 예제에서는 코틀린을 이용한 앱 개발 코드에 집중하기 위해 EdgeToEdge 관련 코드는 모두 제거하고 액티비티가 보여줄 뷰를 설정하는 setContentView()만 남겨두고 설명을 이어가겠습니다.

import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat

class IntroActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_intro) //액티비티가 보여줄 뷰 설정
        
    }//onCreate method....
    
}//MainActivity class...

 

예전버전에서 액티비티를 만들었을 때의 기본 코드와 같습니다.

 

이제 레이아웃 xml 파일에서 만든 Button 을 참조하기 위한 참조변수를 만들어보겠습니다. 보통 액티비티가 보여주는 뷰들은 액티비티 전체에서 사용될 가능성이 많기에 멤버변수(프로퍼티)로 선언하는 것을 권장합니다. 우선 값 변경이 가능한 var 키워드로 변수를 만들어 보겠습니다.

class IntroActivity : AppCompatActivity() {

    var btn:Button   //ERROR - 코틀린은 초기화 없이 멤버변수를 만들 수 없음.
    
    ....
    
 }

 

다만, 코틀린은 멤버변수를 선언할 때 초기화를 하지 않으면 문법적 에러가 발생합니다. 

 

그렇다고 뷰들 찾아와서 객체의 참조값을 리턴해주는 findViewById() 를 호출하여 초기화하면 문법적 에러는 아니지만 런타임(실행 중) 예외가 발생합니다. 이미 자바를 통해 앱을 구현해 보신 분들은 잘 알고 있듯이 xml에 작성한 레이아웃용 뷰들은 액티비티 객체가 생성된 후 자동으로 발동하는 라이프사이클 콜백 메소드인 onCreate()메소드 안에서 setContentView()를 통해 액티비티에 뷰로서 설정되면서 객체로 만들어 집니다. 하지만 멤버변수 btn 은 액티비티가 객체로 생성될 때 초기화를 위해 Button 객체의 참조값을 요구합니다. 즉, Button 객체가 아직 만들어 지기 전에 객체를 찾아오는 findViewById()를 호출하여 참조변수 btn 에 초기화를 하려 하기에 결국 찾지 못하여 null 값이 리턴됩니다. 

class IntroActivity : AppCompatActivity() {

    var btn:Button= findViewById(R.id.btn)    //초기화를 안하면 문법에러 라고 지금 여기서 find를 하면 에러.. 멤버변수가 만들어진 후 onCreate()가 호출되기에 아직 뷰가 만들어지지 않았음.
   
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_intro) //이때 xml 레이아웃파일의 뷰 객체들이 생성됨.
        
    }
}

 

해결 방법은 여러가지가 있기에 앞으로 하나씩 새로운 방법으로 소개하겠습니다.

첫번째 방법으로 자바언어를 사용했을 때 처럼 일단 null 값으로 초기화를 하는 btn 참조변수를 만들고 onCreate() 메소드 안에서 Button 객체를 찾아와서 참조하도록 하겠습니다. 단, 코틀린은 null 값을 저장할 수 있는 변수는 ? 가 표시된 nullable 변수로 만들어야 합니다.

class IntroActivity : AppCompatActivity() {

    var btn: Button?= null      //이렇게 시작은 null 값으로 지정 [nullable type]

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_intro)

        //btn 참조하기
        btn= findViewById(R.id.btn)
        
    }//onCreate method....
}//IntroActivity class........

 

자바로 개발 할 때와 같은 구조 입니다.

이제 버튼 클릭이벤트에 반응하는 리스너를 설정하겠습니다. 클릭에 반응하는 OnClickListener 는 interface 이기에 곧바로 객체를 만들 수 없습니다. 별도의 class 를 만들어 구현(implement)하거나, 객체를 생성하면서 인터페이스를 구현하는 익명클래스를 이용할 수 있습니다.  이 버튼의 클릭에만 반응하는 전용 리스너인 만큼 익명클래스로 구현해 보겠습니다.

자바와코틀린은 익명클래스를 만드는 문법이 완전히 다릅니다. 코틀린에서는 키워드 자체에서 '나 객체야.' 라는 것을 직관적으로 알수 있도록 object 라는 키워드를 사용하여 익명클래스를 만듭니다. 근데 그냥 object 라고만 쓰면 객체이긴 한데 어떤 인터페이스를 구현했는지 알수 없으니 implements 문법으로 사용하는 : 기호를 붙이고 OnClickListener 를 구현하면 됩니다.

class IntroActivity : AppCompatActivity() {

    var btn: Button?= null      //이렇게 시작은 null 값으로 지정 [nullable type]

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_intro)

        //btn 참조하기
        btn= findViewById(R.id.btn)
        
        //Button?타입이기에 null safe 연산자 ?. 사용
        btn?.setOnClickListener(object : View.OnClickListener{  //익명클래스 사용해보기
            override fun onClick(p0: View?) {
                
            }
        })
        
    }//onCreate method....
}//IntroActivity class........

 

btn 변수가 nullable 타입이기에 null 안전 연산자인 ?. 을 이용하여 NPE 에 대응해야만 합니다. nullable 변수를 사용할 때 다소 불편한 점 중에 하나입니다.

문법적 표기법의 차이만 다소 있을 뿐 클릭이벤트 처리 기법을 동일하고 모양도 비슷하여 크게 어렵지 않게 보실 수 있을 겁니다.

 

이제, 버튼의 기능을 onClick()메소드 안에 추가하겠습니다. 이 버튼은 MainActivity 로 전환을 해주는 버튼입니다. 액티비티를 전환하기 위해 Intent 객체를 생성하고 실행하고자 하는 MainActivity의 클래스 정보를 기입하겠습니다. 

 

자바와 다른점

1) new 키워드를 사용하지 않고 Intent 객체 생성

2) 익명클래스 안에서 아웃터클래스(IntroActivity) 객체를 지칭하는 아웃터클래스.thisthis@아웃터클래스명 으로 표기법 변경

 

class IntroActivity : AppCompatActivity() {

    var btn: Button?= null      //이렇게 시작은 null 값으로 지정 [nullable type]

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_intro)

        //btn 참조하기
        btn= findViewById(R.id.btn)
        
        //Button?타입이기에 null safe 연산자 ?. 사용
        btn?.setOnClickListener(object : View.OnClickListener{  //익명클래스 사용해보기
            override fun onClick(p0: View?) {
                //MainActivity로 전환
                val intent: Intent = Intent(this@IntroActivity, MainActivity::class.java)
                startActivity(intent)
                
            }
        })
        
    }//onCreate method....
}//IntroActivity class........

 

 

[ IntroActivity -> MainActivity 액티비티 전환 실행화면 사진 & GIF ]

IntroActivit -> MainActivity 로 전환

 

 

기능적으로는 특별히 어려운 내용은 없습니다. 단지 코틀린에서 달라진 문법적 표기법을 살펴보았습니다.

 

위에서 살펴본 익명클래스를 이용한 버튼 클릭 리스너 처리를 코드가 다소 지저분 합니다. 익명 클래스의 영역 중괄호{} 안에 추상메소드를 구현하기 위한 onClick() 메소드의 중괄호가 중첩으로 구성되어 가독성이 떨어집니다. 이 함수 처리를 간결하게 해주는 문법이 람다표기법입니다. 자바에도 존재하지요. 코틀린에도 람다 표현이 가능합니다. 

 

이번에는 람다 표현을 이용한 버튼 클릭이벤트 처리를 실습해보기 위해 IntroActivity에 [액티비티 종료] 버튼을 하나 더 추가해 보겠습니다. 화면의 우상단 위치에 배치하기 위해 activity_main.xml 레이아웃 파일에 버튼을 하나 더 추가하겠습니다.

activity_intro.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".IntroActivity">

    <!-- 익명클래스로 리스너 처리   -->
    <Button
        android:id="@+id/btn"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="go to Main"
        android:layout_centerInParent="true"/>


    <!-- (람다표현)으로 클릭이벤트 처리   -->
    <Button
        android:id="@+id/btn2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="16dp"
        android:layout_alignParentRight="true"
        android:text="EXIT"
        android:backgroundTint="#FFFF3300"/>

</RelativeLayout>

 

 

이제 이 EXIT 버튼 클릭 처리를 위해 IntroActivity의 코틀린 파일을 수정하겠습니다.

새로 추가한 버튼의 참조변수도 필요합니다. 위에서는 nullable 변수를 사용했었는데 null 안전 문법을 사용하는 것이 불편하니 초기화를 늦게 적용하는 것을 요청하는 '늦은초기화 문법 lateinit' 을 사용해 보겠습니다.

class IntroActivity : AppCompatActivity() {

    var btn: Button?= null      //이렇게 시작은 null 값으로 지정 [nullable type]

    //늦은 초기화 lateinit
    lateinit var btn2: Button
    
    ....
    
}//IntroActivity class.....

 

lateinit 을 통해 늦은 초기화를 하면 멤버변수의 초기화 시점이 참조변수가 처음 사용될 때 실행 됩니다. 

이제 btn2 의 초기화를 onCreate()영역 안에서 수행하겠습니다.

class IntroActivity : AppCompatActivity() {

    var btn: Button?= null      //이렇게 시작은 null 값으로 지정 [nullable type]

    //늦은 초기화 lateinit
    lateinit var btn2: Button

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_intro)

        //btn 참조하기
        btn= findViewById(R.id.btn)
        //Button?타입이기에 null safe 연산자 ?. 사용
        btn?.setOnClickListener(object : View.OnClickListener{  //익명클래스 사용해보기
            override fun onClick(p0: View?) {
                val intent: Intent = Intent(this@IntroActivity, MainActivity::class.java)
                startActivity(intent)
            }
        })

        //btn2 참조하기 [lateinit - 늦은초기화 문법으로 ] 
        btn2= findViewById(R.id.btn2)  //이때 초기화 됨
        
    }//onCreate method...
    
}//IntroActivity class...

 

이제 버튼 클릭처리를 익명클래스인 object 를 사용하지 않고 람다 표현으로 간결화 하겠습니다. 이 람다 표현만으로도 이미 많이 간결해 집니다. 근데 코틀린은 이것 또한 더 줄여서 작성하는 SAM (Single Abstract Method) 변환 이란 문법을 도입했습니다. 메소드의 파라미터를 받기위한 소괄호 () 안에 람다 표현의 중괄호 {} 가 중첩으로 되어 있는 것이 지저분해 함수의 소괄호()가 생략된 문법입니다. 짧게 소개하기는 여의치 않아 별도의 글로 SAM 변환에 대해 소개할 예정입니다. 지금은 앱의 클릭이벤트 처리를 코틀린으로 구현하는 것이 목적이기에 깊게 소개하지 않고 문법적인 사용모습을 보여드리고자 하오니 대략 이렇게 사용하는 구나. 정도로 넘어가시길 바랍니다. 추후 별도로 소개하겠습니다. 얼마나 간결한지 한번 보시죠.

package com.kitesoft.kotlinrecyclerviewapp

import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Button
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat

class IntroActivity : AppCompatActivity() {

    var btn: Button?= null      //이렇게 시작은 null 값으로 지정 [nullable type]

    //늦은 초기화 lateinit
    lateinit var btn2: Button

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_intro)

        //btn 참조하기
        btn= findViewById(R.id.btn)
        //Button?타입이기에 null safe 연산자 ?. 사용
        btn?.setOnClickListener(object : View.OnClickListener{  //익명클래스 사용해보기
            override fun onClick(p0: View?) {
                val intent: Intent = Intent(this@IntroActivity, MainActivity::class.java)
                startActivity(intent)
            }
        })

        //btn2 참조하기 [lateinit - 늦은초기화 문법으로 ]
        btn2= findViewById(R.id.btn2)

        //리스너 설정을 람다 표기법을 이용하면 더 쉽게 코딩 가능. 단, 그 리스너 인터페이스의 추상메소드가 1개일 때[단일 추상 메소드 : SAM (Single Abstract Method]변환으로 구현 가능
        btn2.setOnClickListener {
            finish()   //액티비티 종료
        }
    }//onCreate Method...

}//MainActivity class...

 

클릭 이벤트처리가 정말 간결하네요. 아직 조금만 살펴봤지만 코틀린의 매력을 조금씩 느껴갈 수 있기를 바랍니다. 자바에 비해 분명 요즘의 트랜디한 모던 코딩 문법이어서 맘에 드실겁니다.

 

[ IntroActivity 전체 실행화면 사진 & GIF ]

IntroActivity 의 버튼 클릭에 따른 액티비티전환 및 종료 실행화면

 


 

앱 구현의 기본기능인 클릭이벤트 처리, 액티비티 전환 및 종료에 대하여 코틀린으로 구현해보았습니다.

이제 앱 개발에서 가장 많이 구현하는 기능인 대량의 데이터를 보여주는 RecyclerView 를 사용하는 MainActivity를 만들어보겠습니다.

다음 글에서 보겠습니다.

반응형
반응형

프로그램을 개발하다 보면 기존에 만들었던 class 에 새로운 기능을 추가해야 하는 경우가 있습니다.

만약 기존 class 를 여러분이 직접 설계하여 .kt 파일을 소유한 상태라면 파일을 열어 직접 새로운 기능메소드를 추가하였을 겁니다.

 

근데. 만약 여러분이 제작한 class 가 아니어서 .kt 파일을 보유하지 않았거나, 다른 모듈에서 새로운 기능메소드 없이 기존의 클래스를 사용하고 있어서 원본 class 를 수정하지 못하는 상황이 있을 수도 있습니다. 또는 외부 라이브러리를 통해 제공된 class 인데 새로운 기능을 추가해서 사용해야 할 수 도 있습니다. 하지만 외부에서 제작된 class 이기에 역시 원본 class .kt 파일에 메소드를 추가로 작성하는 것이 불가능 합니다. 이런 경우에는 어떻게 새로운 기능메소드를 추가해야 하는 걸까요?

 

그럴때는 보통 상속을 통해 기존 class 를 가져온 후 새로운 기능 메소드를 추가하는 방법을 사용합니다. 

하지만 코틀린의 class 는 기본적으로 상속이 불가능한 final class 입니다. 상속을 허용하려면 open 키워드를 이용해야 하죠. 즉, 기존의 클래스가 상속이 불가능한 클래스 일 수 도 있습니다. 

 

이를 위해 코틀린은 별도의 상속없이 기존 클래스에 새로운 기능메소드를 추가할 수 있도록 확장(extension) 기능을 제공합니다.

 

기존 클래스의 .kt 원본 파일에 추가되는 것이 아니고 메모리 상에 정적으로 연결되는 기능이기에 실행 중에만 확장이 되어 사용되며 원본에는 영향을 주지 않아 부담없이 그 앱에서만 필요한 기능을 추가해 사용할 수 있는 특징이 있습니다. 

 

실제로 제가 AI 앱 개발을 할 때 촬영된 이미지를 분석하여 object detecting 을 하고 ImageView 가 보여주는 Bitmap 이미지에 오버레이된 도형과 글씨를 표시할 때 기존 ImageView 클래스에 확장함수로 drawBitmapWithBounce() 라는 확장 함수를 추가하여 기능을 구현한 적이 있습니다. 


 

실습을 위해 새로운 Test11ExtensionFunction.kt 코틀린 파일을 만들겠습니다.

프로그램의 시작 함수 main() 까지 작성하고 시작하겠습니다.

 

 

1. 확장 함수 Extenstion Function

기존 클래스에 새로운 기능을 추가하는 대표적인 방법은 상속 Inheritace 입니다. 하지만 상속이 불가능한 final class 는 상속이 불가능 하여 새로운 기능을 추가하지 못합니다. 

상속이 불가능한 클래스에 기능을 추가할 수 있는 아주 손쉬운 방법인 확장 함수를 알아보기 위해 간단한 로봇캐릭터를 이용한 게임을 제작한다고 가정해 보겠습니다.

게임의 메인 캐릭터인 로봇을 설계해야 겠지요. 실습을 위한 것인 만큼 아주 간결하게 멤버변수 1개와 멤버함수 1개로 설계하겠습니다.

 

클래스 명 : Robot

멤버변수(property) :  캐릭터 이름 [  name : String? ]

멤버함수 (method) :  이동 기능 [ move() ]

 

먼저, main() 함수 밖에 Robot class 를 설계하겠습니다.

//시작 함수
fun main(){


}//main 함수 종료..

// [이름, 이동기능]이 설계된 클래스. [ open class 가 아님. 더이상 상속 불가한 클래스 ]
class Robot{

    var name:String?= null

    fun move(){
        println("아장 아장 이동.")
    }

}//------------------------------

 

클래스가 잘 설계 되었는지 확인하기 위해 main()함수에서 Robot 객체를 생성하고 [이동기능]을 사용해 보겠습니다.

//시작 함수
fun main(){

    // Robot 객체 생성
    val robot= Robot()
    // 원래 있는 메소드 호출
    robot.move()
    
}//main 함수 종료..

// [이름, 이동 기능]이 설계된 클래스. [ open class 가 아님. 더이상 상속 불가한 클래스 ]
class Robot{

    var name:String?= null

    fun move(){
        println("아장 아장 이동.")
    }

}//------------------------------


//**출력**
아장 아장 이동.

 

잘 동작 되는 군요. 근데 로봇이 그냥 이동만 하면 게임이 재미 없을 것 같네요. 로봇끼리 서로 싸울 수 있도록 [공격기능]을 추가해 보겠습니다. 통상적으로 특정 클래스에 기능을 추가하려면 원본 클래스 파일을 수정하지만, 이 로봇클래스의 원본은 수정하는 않는 것을 가정하여 제작해 보겠습니다. 원본을 수정할 수 없다면 기능을 상속받아 새로운 기능을 추가하는 것이 보편적입니다. 시도해 볼까요?

// Robot 에게 attack(공격) 가능 추가 하기
// [상속을 통한 기능 추가 시도]
class MyRobot : Robot(){ //error -  final class Robot 은 상속 불가.

}

 

주석으로 설명했듯이 Robot 클래스는 상속이 불가능한 클래스입니다. 프로그램을 개발하다보면 이런 경우가 꽤 존재합니다. 특정 기능을 추가하고 싶은데 final class 여서 상속이 안되기에 별도의 기능함수에 파라미터로 전달하여 대신 처리하는 등 피동적인 기법을 사용할 수 밖에 없습니다. 

이번에 이 글의 주제인 확장 함수를 이용하여 Robot 클래스에 [공격]기능을 추가해 보겠습니다.

// [이름, 이동 기능]이 설계된 클래스. [ open class 가 아님. 더이상 상속 불가한 클래스 ]
class Robot{

    var name:String?= null

    fun move(){
        println("아장 아장 이동.")
    }

}//------------------------------


// Robot 에게 attack(공격) 가능 추가 하기

// [확장 함수 만들기]  -- 클래스명.함수명  으로 새로운 함수 제작
fun Robot.attack(){
    println("주먹 발사!!")
}

 

원본 클래스인 Robot 클래스의 밖이면 어디든 상관없습니다. 함수를 만드는 문법은 그대로 사용하되 함수의 이름앞에 어떤 클래스의 확장함수 인지만 명시해 주시면 됩니다.  [클래스명.함수명]

 

자. 이제 Robot 클래스에 attack() 기능 함수가 추가되었습니다. 이 함수는 메모리에 정적 바인딩되어 있기에 앱이 종료될 때 까지, 즉, 앱이 실행 중에는 Robot 클래스의 멤버메소드 인것 처럼 사용이 가능합니다.

main() 함수에서 생성한 robot 객체에게 확장함수로 만든 [공격 attack 기능]을 사용해 보겠습니다.

//시작 함수
fun main(){

    // Robot 객체 생성
    val robot= Robot()
    // 원래 있는 메소드 호출
    robot.move()

    // 원래 class 작성할 때는 없던 확장 함수 호출. -- 호출의 문법적 차이는 없음.
    robot.attack()

}//main 함수 종료..

// [이름, 이동 기능]이 설계된 클래스. [ open class 가 아님. 더이상 상속 불가한 클래스 ]
class Robot{

    var name:String?= null

    fun move(){
        println("아장 아장 이동.")
    }

}//------------------------------


// Robot 에게 attack(공격) 가능 추가 하기

// [확장 함수 만들기]  -- 클래스명.함수명  으로 새로운 함수 제작
fun Robot.attack(){
    println("주먹 발사!!")
}


//**출력**
아장 아장 이동.
주먹 발사!!

 

너무 쉽고 간편하게 기능을 추가하였습니다.

자바에는 없는 문법입니다.[참고로. kotlin 파일은 컴파일 되면 java 로 변환됩니다. 이 확장함수는 자바에서는 static 함수로 대체됩니다.]

 

이번에는 확장함수에 파라미터를 전달해볼까요? 더불어 클래스의 중괄호 영역 밖에서 만든 확정함수안에서 원본 클래스의 영역안에 선언된 멤버변수를 인식할 수 있을 까요? 뭐. 미리 정답을 말하면 당연히 가능합니다. 원래 있는 기능처럼 사용하는 것인 만큼 당연히 허용됩니다.

아! 조심하세요. 위에 참고사항으로 설명했듯이 자바로 변환될때 static 함수를 만들고 파라미터로 객체를 전달받아 해당 기능을 동작하도록 하기에 외부에서 접근이 불가능한 private 접근 제한자가 있는 멤버변수는 사용이 불가능합니다. 그러니 엄밀하게는 기존 메소드와 완전히 같지는 않습니다.

 

일단, 코틀린에서는 대부분 private 으로 프로퍼티를 제작하지 않으니 이는 잠시 뒤로 하고 멤버변수를 사용하는 확정함수를 만들어 보겠습니다.

// [확장 함수 만들기2 - 멤버 변수 사용해 보기 (이름을 매개변수로 전달받아 멤버변수에 대입하기)]
fun Robot.fly(name: String){
    this.name= name
    println("${this.name} 난다~~ 난다~~~")
}

 

잘 동작하는 지 main()함수에서 로봇객체의 이름을 파라미터에 전달하면서 확장함수 fly 기능을 사용해 보겠습니다.

//시작 함수
fun main(){

    // Robot 객체 생성
    val robot= Robot()
    // 원래 있는 메소드 호출
    robot.move()

    // 원래 class 작성할 때는 없던 확장 함수 호출. -- 호출의 문법적 차이는 없음.
    robot.attack()

    // 파라미터 값 전달 및 멤버변수를 사용하는 확장함수 호출
    robot.fly("옵티머스 프라임")

}//main 함수 종료..

// [이름, 이동 기능]이 설계된 클래스. [ open class 가 아님. 더이상 상속 불가한 클래스 ]
class Robot{

    var name:String?= null

    fun move(){
        println("아장 아장 이동.")
    }

}//------------------------------


// Robot 에게 attack(공격) 가능 추가 하기

// [확장 함수 만들기]  -- 클래스명.함수명  으로 새로운 함수 제작
fun Robot.attack(){
    println("주먹 발사!!")
}

// [확장 함수 만들기2 - 멤버 변수 사용해 보기 (이름을 매개변수로 전달받아 멤버변수에 대입하기)]
fun Robot.fly(name: String){
    this.name= name
    println("${this.name} 난다~~ 난다~~~")
}

//**출력**
아장 아장 이동.
주먹 발사!!
옵티머스 프라임 난다~~ 난다~~~

 

 

확장함수는 내가 만든 클래스에만 적용할 수 있는 것이 아닙니다.

이번에는 Kotlin APIs 클래스 중에서 상속이 불가능한 String 클래스에 새로운 기능 추가해 보겠습니다.

//시작 함수
fun main(){

    // Kotlin APIs 로 제공되는 final class String 에 새로운 확장 함수 사용하기!
    "안녕하세요.".twicePrint()

}//main 함수 종료..

// String 클래스에 새로운 기능 추가!!
fun String.twicePrint(){
    println("$this $this")
}

 

 

제네릭이 있는 컬렉션 List 에도 확장함수 적용이 가능합니다. 

//시작 함수
fun main(){

    // 제네릭 사용 클래스에 확장 함수 사용하기!
    val scores= listOf(90,88,75,68,95,100)
    val total= scores.total()
    println("성적 총합 : $total")    

}//main 함수 종료..

// 제네릭 타입에 확장함수 추가하기 - 정수값들을 가진 리스트의 총합을 구해서 리턴해주는 기능 추가!!
fun List<Int>.total():Int{
    var total:Int=0
    this.forEach { total += it }
    return total
}


//**출력***
성적 총합 : 516

 

단, 같은 리스트여도 제네릭 타입이 다르면 확장함수를 사용할 수 없습니다.

// 같은 리스트여도 제네릭 타입이 다르면 확장함수 사용 불가
val scores2= listOf(60.5, 43.5, 66.7)
val total2= scores2.total() //error - 확장함수 안됨..

 

 

확장 함수 기타 특성

- 확장 프로퍼티(멤버변수)도 가능함.

- 오버로딩 Overloading 가능함.

- 오버라이드 Override 는 불가능함.

- 기존에 클래스에 있는 메소드명과 같은 이름의 확장함수를 만들어도 에러는 아니지만 기존의 메소드가 우선시 됨.

- package 가 다른 곳에서 만든 확장 함수를 사용하려면 import 해야 함.

 

 

반응형
반응형

Kotlin은 자바에는 없는 Scope(스코프 : 범위) Function 이라는 문법이 존재합니다. 그 이름 처럼 특정 객체의 범위(scope)를 만들어주는 함수로서 객체의 멤버 여러개를 접근해야 할 때 그 객체만의 범위를 만들어주는 함수로서 코드의 가독성이 아주 좋아집니다.

 

♣ Scope function :

- 객체의 멤버를 실수없이 편하게 사용할 수 있는 범위(영역)을 만들어내는 함수 - 람다식을 이용하여 영역처리, 가독성 향상

 

스코프 함수에 대한 문법을 소개하기 전에 이 함수의 효용성을 느낄 수 있도록 클래스를 하나 만들고 그 멤버들을 사용하는 상황은 살펴보겠습니다.

 


 

실습을 위해 Test10ScopeFunction.kt 코틀린 파일을 만들겠습니다.

프로그램의 시작함수 main()를 작성하겠습니다.

 

1. 스코프 함수의 등장

자바에는 없었던 스코프 함수가 왜 등장하게 되었는지를 느껴보기 위해 멤버변수가 여러개인 클래스를 하나 설계해 보겠습니다.

 

어떤 그룹에 소속한 사람들(크루원)의 [이름, 나이, 주소] 정보를 관리하는 앱을 만들어야 한다고 예를 들어보겠습니다.

사람 한명당 [이름, 나이, 주소] 3개의 데이터를 저장해야 하기에 그룹화 하여 관리하도록 Crew 라는 이름으로 클래스를 설계해 보겠습니다. 편의상 별도의 .kt 파일보다는 main()함수 아래에 작성하겠습니다.

//시작함수
fun main(){


}//main 함수 종료...

//어떤 그룹의 크루원 정보 저장 클래스 
class Crew{
    
    //property [ 이름, 나이, 주소 ]
    var name:String?= null
    var age:Int?= null
    var address:String?= null

    //method - property 출력 기능
    fun show(){
        println("$name : $age , $address")
    }
}

 

이제, Crew 클래스를 사용하기 위해 객체로 생성 한 후 멤버변수에 값을 대입하고 출력해보는 코드를 작성해 보겠습니다. 

//시작함수
fun main(){

    //Crew클래스 객체 생성 및 멤버 여러개를 사용해보기
    val crew= Crew()    
    crew.name="sam"
    crew.age= 20
    crew.address="seoul"
    crew.show()

}//main 함수 종료...

//어떤 그룹의 크루원 정보 저장 클래스 
class Crew{
    
    //property [ 이름, 나이, 주소 ]
    var name:String?= null
    var age:Int?= null
    var address:String?= null

    //method - property 출력 기능
    fun show(){
        println("$name : $age , $address")
    }
}

//**출력**
sam : 20 , seoul

 

이미 객체에 대한 학습을 진행했다면 어렵지 않게 객체를 생성하고 멤버를 사용하기 위해 . 연산자로 멤버에 접근하여 사용했을 겁니다. 여기까지는 특별한 내용이 없습니다. 자바에서도 많이 했던 작업입니다.

 

근데. 조금 생각해 볼까요? 위 Crew 객체의 멤버는 총 4개 입니다. property 3개, method 1개 입니다.

이 멤버 4개(변수3,메소드1)를 사용할때 마다 객체명.xxx 라고 쓰는게 생각보다 번거롭고 코드의 실수가능성도  많으며 가독성도 떨어집니다. 위 코드를 보면 멤버를 사용할 때 마다 crew.neme , crew.age, crew.xxx 형태로 매번 crew 라고 쓰는거 좀 짜증입니다. 다행히 지금은 객체명이 다소 짧은 crew 였습니다. 4글자에 불과하죠. 근데 만약 더 길다면? 예를 들어 sharedPreferences 처럼 긴 이름를 사용해야 한다면 매번 작성하기 너무 번거롭습니다. 복사붙이기로도 해소하기 곤란한 내용이며 타자 실수도 나올 가능성이 많습니다. 이를 위해 등장한 문법이 Scope(스코프:범위) functioin(함수) 입니다.

 

스코프 함수의 종류는 5개 정도 있는데 일단, 이 중에 가장 많이 사용되는 apply 를 이용하여 간략하게 소개하겠습니다.

코틀린의 모든 객체는 기본적으로 스코프함수를 사용할 수 있으며 함수이지만 통상 범위 영역을 만들기위해 람다식으로 { } 를 작성합니다.

//시작함수
fun main(){

    //Crew클래스 객체 생성 및 멤버 여러개를 사용해보기
    val crew= Crew()    
    crew.name="sam"
    crew.age= 20
    crew.address="seoul"
    crew.show()
    
    //스코프 함수로 멤버 여러개 사용해보기
    var crew2= Crew()
    crew2.apply {
        //이 apply{} 영역 안에서는 this가 crew2를 의미함. 또한 this는 생략이 가능함
        this.name= "robin"
        age= 25
        address="busan"
        show()
    }

}//main 함수 종료...

 

코드에서 볼 수 있듯이 특정 객체(crew2)의 멤버 범위(scope) 영역을 만들어주는 스코프함수 apply의 중괄호 {} 영역 안에서는 객체명을 사용할 필요 없이 마치 class 내부에서 처럼 this 키워드를 사용할 수 있습니다. 이 this 참조변수가 바로 crew2를 의미하게 되는 문법입니다. 즉, crew2.name 대신에 this.name 으로 작성이 가능하며 나아가 class의 중괄호 {} 안에서 그랬듯이 this 키워드는 생략이 가능하기에 this.age 를 범위 안에서는 this 없이 그냥 age만 작성할 수 있어 코드가 매우 간결해 지는 특징이 있는 문법입니다.

위처럼 영역을 묶었기에 참조변수명을 잘못 기입하는 실수를 줄일 수 있고 다른 개발자가 볼때도 crew2에 대한 설정들을 하나의 영역에 묶어서 가독성이 좋아집니다.

 

※ 원래는 아래처럼 객체생성후 apply 라는 이름의 함수를 추가하는 것입니다. 일종의 확장함수(extension) 같습니다.  이를 람다식처럼 줄여서 ()를 생략한 문법인 것 입니다. 거의 대부분 람다식으로 작성합니다.

// 원래는 이런식으로 객체생성후 apply 라는 이름의 함수를 추가하는 것임( 일종의 확장함수 ) -- 이를 람다식처럼 줄여서 ()를 생략한 문법임
crew2.apply(){
}

 

 

2. 스코프 함수의 종류

스코프 함수는 크게 2가지 분류로 구분할 수 있습니다.

 

1) 영역안에서 this 키워드로 본인을 참조하는 scope function : apply, run
2) 영역안에서 this 키워드 대신 it으로 본인객체를 참조하는 scope function : also, let   [ 마치 람다식 처럼 ]

 

두 분류의 차이를 대략적으로 알아보고 자제한 내용을 이어서 소개하겠습니다.

영역안에서 this 키워드로 본인을 참조하는 것은 위에서 살펴보았으니 it 키워드로 본인을 참조하는 스코프 함수 중 also 를 이용하여 crew의 멤버값을 사용해 보겠습니다.

//시작함수
fun main(){

    //Crew클래스 객체 생성 및 멤버 여러개를 사용해보기
    val crew= Crew()    
    crew.name="sam"
    crew.age= 20
    crew.address="seoul"
    crew.show()
    
    //스코프 함수로 멤버 여러개 사용해보기
    var crew2= Crew()
    crew2.apply {
        //이 apply{} 영역 안에서는 this가 crew2를 의미함. 또한 this는 생략이 가능함
        this.name= "robin"
        age= 25
        address="busan"
        show()
    }
    
    //영역안에서 it을 사용하는 스코프 함수
    val crew3= Crew()
    crew3.also {
        // also{} 영역 안에서는 it이 crew3을 의미함. it은 생략 불가
        it.name= "hong"
        it.age= 30
        it.address="paris"
        it.show()
    }

}//main 함수 종료...

 

코드에서 확인 가능하듯이 영역안에서 this 를 it 으로 변경한 것만 차이가 있고 기능적인 부분은 똑같기에 상황에 따라 어떤 스코프 함수를 사용하든 상관없습니다. 경우에 따라 더 적합한 것이 다르기에 지금 당장 어떤 것을 써야할 지 확실히 구분하려 할 필요는 없습니다. 경험이 쌓이면 자연스럽게 선택하여 사용이 가능해 집니다. 조금해 하지 마세요.

 

그런데. 이렇게 2개의 분류가 있으니 스코프 함수도 2개면 될 것 같은데 각 분류마다 2개씩 존재하는 이유가 뭘까요?

if 표현식을 기억하시나요? if문의 마지막 실행문의 결과 값을 리턴하여 사용할 수 있습니다.

스코프 함수도 리턴이 있습니다. 단지 함수 축약표현에 의해 생략된 것입니다. 

이 리턴값이 다릅니다. 한 종류는 객체 본인의 참조값을 리턴하고, 또 다른 종류는 마지막 실행문의 값이 리턴됩니다.

 

정리하면.

  리턴값이 본인객체 리턴값이 마지막 실행문의 값
영역안에서 this 키워드 사용 apply run
영역안에서 it 키워드 사용 also let

 

 

각각의 사용 모습을 알아보겠습니다.

 

1) 영역안에서 this 키워드 사용

 

1.1) apply - 본인객체를 return

// 1.1) apply - 본인객체를 return
val crew4:Crew= Crew()
val crew5:Crew= crew4.apply {
    name="kim"   //this. 키워드 생략 가능
    age= 40
    address="newyork"
    //별도의 리턴 명시가 없지만 무조건 return this 인것임
}
crew5.show()

 

1.2) run - 마지막 실행문이 return 값

// 1.2) run - 마지막 실행문이 return 값
val crew6:Crew= Crew()
val len= crew6.run {
    name="park"
    age= 45
    address="tokyo"
    name?.length //마지막 실행문이 리턴값  - 글자수 4 리턴됨
}
println("이름의 글자수 : $len")

 

 

2) 영역안에서 it 키워드 사용

 

2.1) also -  본인객체를 return

//2.1) also - it 키워드를 사용하며 본인객체를 return
val crew7:Crew= Crew()
val crew8:Crew= crew7.also {
    it.name="lee"
    it.age= 50
    it.address="incheon"
    //별도의 리턴 명시가 없지만 무조건 return this 인것임
}
crew8.show()

 

2.2) let - 마지막 실행문이 return 값

//2.2) let - it 키워드를 사용하며 마지막 실행문이 return 값
val crew9:Crew= Crew()
val n:String?= crew9.let {
    it.name="choi"
    it.age=20
    it.address="LA"
    it.name?.uppercase()  //마지막 실행문의 결과가 리턴   -- 이름을 대문자로 변경한 결과값 "CHOI" 리턴됨
}
println("이름의 대문자 출력 : $n")

 

 

 

추가로. 위 4개의 scope 함수의 문법적 표기법이 다소 다른 scope 함수가 존재합니다. 

영역을 만들려는 객체에게 스코프함수를 적용하는 것이 아니라 스코프함수의 영역에 객체를 전달하는 형태의 모습입니다. 

 

3) with 

스코프함수 run과 비슷한 동작을 하지만 문법적 사용이 다른 스코프 함수 입니다.

//** scope function 중 run과 비슷한 동작을 하는 with가 있음. 사용문법이 약간 다름.... **
val crew10:Crew= Crew()
with(crew10){      //범위 안에 객체를 파라미터로 전달받는 형태
    name="aaa"
    age=20
    address="HANOI"
    show()
    name?.lowercase()  //리턴값을 마지막 실행문의 결과
}

 

 

5가지 스코프 함수를 알아봤는데 앞서 설명했듯이 스코프 함수를 많이 사용하다 보면 자연스럽게 상황에 맞는 종류의 스코프함수를 선택하여 사용하게 됩니다. 지금부터 완벽하게 효율적으로 구분하여 사용하려고 노력하지 않아도 됩니다.


 

스코프 함수는 앱 개발에서 같은 객체에게 여러작업을 할때 유용하게 사용할 수  있습니다.

[ex. AlertDialog.Builder, Notification.Builder, SharedPreference.Editor ....]

 

마지막으로, 안드로이드 앱 개발에서 스코프 함수를 사용하는 코드의 예를 보여드리겠습니다.

현재 파일은 일반 코틀린 파일이기에 따라해도 에러가 발생합니다. MainActivity.kt 액티비티 안에서 사용하는 것을 가정하여 코드만 소개하는 것이니 눈으로만 보시며 스코프함수의 효용성을 느껴보시길 기대합니다.

 

- 스코프 함수를 사용하지 않고 다이얼로그를 생성하는 코드  ( 스코프 함수 X )

val builder:AlertDialog.Builder= AlertDialog.Builder(this)
builder.setTitle("aaa")
builder.setMessage("bbbb")
builder.setPositiveButton("OK", null)
builder.setNegativeButton("CANCEL", null)
val dialog=builder.create()
dialog.show()

 

- 스코프 함수를 사용하여 다이얼로그를 생성하는 코드  ( 스코프 함수 O )

val builder2:AlertDialog.Builder= AlertDialog.Builder(this)
val dialog2= builder2.run{
    setTitle("sss")
    setMessage("asdfasdfa")
    setPositiveButton("확인", null)
    setNegativeButton("취소", null)
    create()      // 빌더가 생성하는 dialog 객체가 리턴됨
}
dialog2.show()
반응형
반응형

이번 글은 대부분의 개발자들을 고통스럽게 했던 null 값에 대한 코틀린의 대응 문법에 대한 내용입니다.

 

참조변수가 객체를 참조하지 않은 상태에서 객체의 기능을 사용하려 했을 때 많이 만나는 NullPointerException[NPE]은 코드를 작성할 때 발견되지 않고 앱이 실행중에 발생하기에 코딩 중 문제를 발견하기 어렵고 초보자들의 경우 예외처리에 대한 부분에 미흡하여 앱이 다운되는 주요 원인 중 하나가 되어 개발자들 사이에 아주 짜증나는 문제 중 하나입니다. 

NPE는 이 문제의 원인을 제공한 null 이라는 값을 만든 토니호어(Tony Hoare) 조차도 2009년 강연에서 이 문제에 대해 본인을 자책하는 말을 남겨 더욱 유명해 졌습니다.

 

null값 : 토니 호어'(Tony Hoare)가 1965년에 처음 발명, -- 퀵정렬 이론을 만든 사람

 

2009년 강연 중..

 

 (널 포인터는) 내 10억 달러짜리 실수였다. 1965년 당시, 나는 ALGOL W라는 객체 지향 언어에 쓰기 위해 포괄적인 타입 시스템을 설계하고 있었다.
    내 원래 목표는 어떤 데이터를 읽든 항상 안전하도록 컴파일러가 자동으로 확인해 주는 것이었다.
그러나 나는 널 포인터를 집어넣으려는 유혹을 이길 수가 없었다.

    그렇게 하는 게 훨씬 쉬웠기 때문이다. 이 결정은 셀 수도 없는 오류와 보안 버그, 시스템 다운을 낳았다.
지난 40년 동안 이러한 문제들 때문에 입은 고통과 손해는 10억 달러는 될 것이다.



 

 

코틀린은 NullPointerException[NPE]에 대한 앱의 버그를 문법에서부터 막아주기 위한 Null safety 관련 문법들을 제공합니다.

 

새로운 파일 Test09NullSafe.kt 을 만들고 문법들에 대해 알아봅시다.

 

프로그램의 시작함수 main() 함수를 만들고 문법들에 대해 알아보겠습니다.

 

 

1. null 값을 저장할 수 있는 nullable 변수

코틀린은 null값을 저장할 수 있는 타입을 명시적으로 구분하여 사용하도록 하고 있습니다. 이를 통해 코드를 작성하는 시점에 이 변수가 널값을 가지고 있을 수 있다는 것을 개발자에게 인식하게 하며 문법적으로 강제로 null 값에 대한 처리를 하도록 하였습니다.

 

1.1) non nullable 변수 - null 값을 저장할 수 없는 변수 

var s1:String  = null //ERROR - non nullable 변수

 

1.2) nullable 변수 - null 값을 저장할 수 있는 변수 [ 자료형(type)뒤에 ? 추가]

var s2:String? = null //OK  - nullable 변수

 

 

두 타입의 변수가 어떻게 다른지 알아보면서 nullable 변수를 안전하게 사용하기 위한 몇가지 특별한 연산자 들을 소개하도록 하겠습니다.

fun main(){

    //null 값을 저장할 수 없는 변수와 있는 변수.
    var str1:String = "Hello"   // non nullable type
    var str2:String? = "Nice"   // nullable type

}

 

두 참조변수 모두 실제 문자열객체를 참조하고 있습니다. 이제 각각의 참조변수를 이용하여 문자열 객체의 글자수(길이)를 화면에 표시해보겠습니다. 이미 잘 알고 있듯이 String 객체는 본인이 가진 문자열의 길이값을 length 라는 프로퍼티(멤버변수)에 저장하고 있습니다.

 

먼저 null 값을 저장할 수 없는 non nullable variable 인 str1 변수의 length를 사용해 보겠습니다. 자바와 특별히 다른 점이 없습니다.

fun main(){

    //null 값을 저장할 수 없는 변수와 있는 변수.
    var str1:String = "Hello"   // non nullable type
    var str2:String? = "Nice"   // nullable type
    
    //non nullable 변수가 참조하는 문자열 객체의 글자수(길이) 출력하기
    println("글자수 : " + str1.length)    // OK :   . 연산자를 통해 String객체의 멤버 접근하여 사용

}

//**출력**
글자수 : 5

 

이번에는 null 값을 저장할 수 있는 nullable variable 인 str2 변수의 length를 사용해 보겠습니다. ERROR가 발생하는 것을 확인하세요.

fun main(){

    //null 값을 저장할 수 없는 변수와 있는 변수.
    var str1:String = "Hello"   // non nullable type
    var str2:String? = "Nice"   // nullable type
    
    //non nullable 변수가 참조하는 문자열 객체의 글자수(길이) 출력하기
    println("글자수 : " + str1.length)    // OK :   . 연산자를 통해 String객체의 멤버 접근하여 사용
    
    //nullable 변수가 참조하는 문자열 객체의 글자수(길이) 출력시도하기
    println("글자수 : " + str2.length)  // ERROR :  String? 타입은 null 일 수도 있다는 것이기에 . 연산자가 언제나 안전하게 멤버를 가져올 수 있다고 확신할 수 없음 - 실제 null 이 아니어도 사용불가 [ 객체가 없을때 NullPointException 발생 ]

}

 

str1 과 다르게 문법적 에러를 발생합니다. str2 참조변수가 실제 문자열 객체를 참조하는 것과는 상관없이 nullable 타입은 언제든 객체를 참조하지 않을 수도 있기에 컴파일 단계에서부터 개발자에게 null 값인지를 체크하는 코드를 추가하여 런타임(실행 중) 예외가 발생하지 않도록 코드를 작성하도록 강제하는 것입니다.

 

그럼 해결해볼까요? 일단, 자바에서 처럼 if 조건문을 통해 null 체크 코드로 해결해 보겠습니다.

fun main(){

    //null 값을 저장할 수 없는 변수와 있는 변수.
    var str1:String = "Hello"   // non nullable type
    var str2:String? = "Nice"   // nullable type
    
    //non nullable 변수가 참조하는 문자열 객체의 글자수(길이) 출력하기
    println("글자수 : " + str1.length)    // OK :   . 연산자를 통해 String객체의 멤버 접근하여 사용
    
    //nullable 변수가 참조하는 문자열 객체의 글자수(길이) 출력시도하기
    //println("글자수 : " + str2.length)  // ERROR :  String? 타입은 null 일 수도 있다는 것이기에 . 연산자가 언제나 안전하게 멤버를 가져올 수 있다고 확신할 수 없음 - 실제 null 이 아니어도 사용불가 [ 객체가 없을때 NullPointException 발생 ]
    
    //해결방법 - null인지 확인하는 if문 코드 필요
    if(str2!=null) println("글자수 : " + str2.length) //if문이 참일때만 실행되는 영역이어서 String?도 사용가능함.

}

//**출력**
글자수 : 5
글자수 : 4

 

if() 조건문이 참(true)인 영역에 작성하기에 str2는 무조건 객체를 참조하고 있다는 것이 확실하기에 컴파일 단계에서 에러로 표시하지 않도록 합니다. 자바에서 많이 사용하던 null check 방법입니다. 

근데 위 해결방법이 좀 코드가 번거로운 것 같습니다. 그래서 등장한 코틀린의 null 안정성 문법을 소개하고자 합니다.

 

♣ null을 안전하게 사용하기 위한 몇가지 연산자

 

1) ?. 연산자  : null safe 연산자

코틀린은 if 처리 코드를 조금 더 간결하게 표시하기 위해 null 이 아닐때만 . 연산자로 멤버를 접근하는 널 안전 연산자를 제공합니다.

println("글자수 : " + str2?.length)  // null이 아니면 멤버에 접근, null이면 그냥 null 을 결과로 줌

//**출력**
글자수 : 4

 

만약 str2 가 정말로 null 이라면 null 값이 출력됩니다.

str2= null;
println("글자수 : " + str2?.length)  // null 출력

//**출력**
글자수 : null

 

근데 객체가 null일때 그냥 null로 값이 전달되는 것이 싫고 내가 원하는 값으로 나왔으면 할 수 도 있습니다.
만약 객체가 null이면 길이값을 -1 로 주고 싶다면 아래처럼 if - else 를 사용해야 할겁니다.

val len= if(str2!=null) str2.length else -1
println(len)

//**출력**
-1

 

근데 if 표현식이 조금 지저분해 보이네요. 이를 간결하게 하기 위한 2번째 연산자를 소개하겠습니다.

 

 

2) ?: 연산자 -  엘비스[Elvis] 연산자

연산자에 사용된 ? 의 모양이 엘비스 프레슬리의 머리부터 구렛나루 모양과 흡사하다고 해서 엘비스 연산자 라고 부릅니다. 좀 어이없지만요. ?: 연산자의 앞에 작성한 참조변수가 null 이면 ?: 연산자 뒤에 작성된 값이 결과값이 되는 연산자 입니다. 

val len2= str2?.length ?: -1   // ?: 연산자 뒤에 null일때 원하는 값을 지정
println(len2)

//**출력**
-1

 

위 if - else 코드에 비해 훨씬 간결해 진 코드를 볼 수 있습니다. 아주 맘에 드는 연산자 입니다. 연산자 이름도 맘에 듭니다.^^

 

위 2가지 null 안전 연산자를 이용하여 코드를 작성하기 하지만 ? 를 표시하는 것이 귀찮거나 굳이 nullable variable 로 결과값을 받아 사용하는 것이 필요치 않을수도 있습니다.

 

이런 NPE에 안전한 연산자를 쓰지 않고 그냥 자바에서처럼 실수로 null 참조변수를 사용하면 Exception이 발생하여 앱이 종료되로록 하고싶다면?
즉, 개발자가 의도하지 않게 null이면 앱이 꺼지도록 하여 앱을 더이상 실행되게 하고 싶지 않다면 이 참조변수가 null 이 아니라고 주장하면 됩니다. 

 

 

3) non-null asserted(주장된,단언된) call !! 연산자

이 참조변수는 null 이 아니니 컴파일단계에서 에러로 표시하지 말라고 주장하는 연산자 입니다.

var ss:String?= "Hello"
//println( ss.length )  //ERROR - String? 타입은 NPE 발생이 안되도록 문법적으로 못쓰도록 하고 있음
println( "글자수 : " + ss!!.length  )

//**출력**
글자수 : 5

 

만약 실제 참조변수가 null 을 참조하고 있었다면 컴파일 단계에서는 에러는 아니지만 런타임(실행 중)에 예외가 발생하여 앱이 다운됩니다.

//null참조객체
val sss:String?= null
println( "글자수 : " + sss!!.length  ) //Exception -- 실행 중 예외발생

//**출력**
Exception in thread "main" java.lang.NullPointerException

 

 

마지막으로 null에 대한 대응은 아니지만 안전하고 간결한 예외처리를 제공하는 연산자를 소개하고 마무리 하겠습니다.

 

4) 안전한 casting 연산자 as?  - 자료형이 맞지 않는 타입을 억지로 형변환 하는 경우

코틀린은 참조형 타입의 형변환을 위한 연산자로 as( ~로서) 를 사용합니다. 그러다 만약, 형변환이 불가능한 타입으로 변환을 시도하면 런타임(실행 중) 예외가 발생하여 앱이 다운됩니다. 컴파일 단계에서 에러를 표시하지 않기에 개발자가 발견하기 어려운 예외입니다.

이를 위해 형변환 중 예외가 발생하면 그냥 null 값으로 결과를 주는 안전한 형변환(type casting) 연산자가 as? 입니다.

 

참조형 타입의 형변환을 실습해 보기 위해 main()함수 아래에 MMM 과 ZZZ 라는 이름의 클래스를 설계하겠습니다. 

멤버변수는 둘다 a 변수 1개씩 가진 비슷한 모양이지만 둘은 전혀 상관없는 class 입니다. 서로 연관이 없는 클래스이기에 형변환이 불가능한 클래스입니다.

//시작 함수..
fun main(){

}// main() 함수 종료..

//4) 안전한 캐스팅 실습용 클래스들
class MMM{
    var a=10
}

class ZZZ{
    val a=20
}

 

main 함수에 MMM 객체를 생성한 후 zzz 참조변수에 대입해 보겠습니다.

fun main(){

    val mmm:MMM= MMM()

    //전혀 다른 타입 참조변수에 대입
    val zzz:ZZZ= mmm       //문법적으로 error - 타입이 전혀 다르므로.
    
}

//4) 안전한 캐스팅 실습용 클래스들
class MMM{
    var a=10
}

class ZZZ{
    val a=20
}

 

당연히 컴파일 단계에서 문법적으로 에러를 발생합니다. 전혀 다른 타입을 대입하려 했으니 당연합니다.

이때, 억지로 MMM객체를 ZZZ 타입으로 형변환 하여 대입해보겠습니다.

fun main(){

    val mmm:MMM= MMM()

    //전혀 다른 타입 참조변수에 대입
    //val zzz:ZZZ= mmm       //문법적으로 error - 타입이 전혀 다르므로.
    
    //억지로 as 연산자를 통해 형변환
    val zzz:ZZZ = mmm as ZZZ  //문법적으로는 error가 없지만 Class cast Exception 발생함!
    
}

//4) 안전한 캐스팅 실습용 클래스들
class MMM{
    var a=10
}

class ZZZ{
    val a=20
}

 

명시적인 형변환 연산자 as 를 사용함으로서 컴파일 단계에서는 에러로 인지하지 않습니다. 하지만 실제 런타임(실행 중)에 형변환이 불가능하여 예외가 발생하며 앱이 다운됩니다. 

 

이때 혹시 잘못된 casting을 해도 예외로 인해 앱이 다운되지 않고 그냥 null을 리턴하도록 하고 싶다면 안전한 캐스팅 연산자 as? 를 사용합니다.

fun main(){

    val mmm:MMM= MMM()

    //전혀 다른 타입 참조변수에 대입
    //val zzz:ZZZ= mmm       //문법적으로 error - 타입이 전혀 다르므로.
    
    //억지로 as 연산자를 통해 형변환
    //val zzz:ZZZ = mmm as ZZZ  //문법적으로는 error가 없지만 Class cast Exception 발생함!
    
    //이때 혹시 잘못된 casting을 해도 예외로 인해 앱이 다운되지 않고 그냥 null을 리턴하는 안전한 캐스팅 연산자 as?
    val zzz:ZZZ? = mmm as? ZZZ?
    println(zzz) // null출력   - 객체가 실제로 캐스팅이 되었다면 [클래스명@해시코드] 가 출력됨
    
}

//4) 안전한 캐스팅 실습용 클래스들
class MMM{
    var a=10
}

class ZZZ{
    val a=20
}

 

 

반응형

+ Recent posts