1.  컨스트레인트 레이아웃  ConstraintLayout

한 화면을 구성하는 뷰들에 서로 제약을 주는 레이아웃. 컨스트레인트 레이아웃을 자주 쓰는 가장 큰 이유는 '다양한 화면 크기에 대응하는 반응형 UI를 쉽게 구성'할 수 있기 때문. 또 중첩된 레이아웃을 사용하지 않고도 크고 복잡한 레이아웃을 만들 수 있어 성능면에서 유리함

 


1)  기본 속성

  👾  컨스트레인트 레이아웃에서 자식 뷰의 위치를 정의하려면 자식 뷰의 수직 / 수평 방향에 제약 조건을 각각 하나 이상 추가
  👾  자식 뷰에 아무런 제약도 추가하지 않으면 왼쪽 상단에 배치. 컨스트레인트 레이아웃에서 자주 쓰는 속성은 다음과 같음.

app:layout_constraint[내 방향]_to[기준 뷰 방향]Of = "[기준 뷰 ID or parent]"


  ✓  [내 방향]을 [기준 뷰 방향]에 맞추고 그 기준 뷰가 무엇인지 알려줌

 

예제
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <Button
        android:id="@+id/button_1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        
        // 시작 부분을 부모의 시작부분에 맞춤
        app:layout_constraintStart_toStartOf="parent"
        android:layout_marginStart="32dp"
        // 윗면을 부모의 윗면에 맞춤
        app:layout_constraintTop_toTopOf="parent"
        android:layout_marginTop="100dp"
        
        android:text="Button 1" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        
        // 시작부분을 버튼 끝부분에 맞춤
        app:layout_constraintStart_toEndOf="@+id/button_1"
        // 밑부분을 버튼부분에 맞춤
        app:layout_constraintBottom_toBottomOf="@+id/button_1"
        // 윗부분을 버튼 윗부분과 맞춤
        app:layout_constraintTop_toTopOf="@+id/button_1"
        android:layout_marginStart="32dp"
        android:text="버튼을 클릭해주세요!" />


</androidx.constraintlayout.widget.ConstraintLayout>

 


2)  컨스트레인트 레이아웃에서 마진을 줄 때 주의점

  👾  자식 뷰 사이에 여백을 정해줄 때 레이아웃에서는 layout_margin 속성을 사용
  👾  리니어 레이아웃과 상대적 레이아웃에서는 별다른 고려 없이 사용해도 되지만, 컨스트레인드 레이아웃을 사용할 때는 반드시 해당 방향으로 제약이 존재해야 마진값이 적용되는 규칙이 있음

예를 들면 위쪽 방향으로 여백을 100dp 만큼 주고 싶을 경우, 위쪽 방향에 제약이 없는 상태에서 android:layout_marginTop = "100dp"를 하면 적용이 되지 않음. 그러므로 반드시 android:layout_constraintTop_toTopOf="parent"와 같은 위쪽 방향에 제약을 추가할 수 있는 속성을 추가해야 함.

 


3)  match_constaint 속성

컨스트레인트 레이아웃을 이용해 앱의 레이아웃을 구성하다 보면 layout_width와 layout_height 속성에서 빈번하게 0dp 값을 보게 됨
그 뷰의 너비나 높이가 0dp라는 것은 match_constraint와 같음. match_parent가 부모 레이아웃 크기에 뷰 크기를 맞춘다는 것이라
 match_constraint는 제약에 뷰 크기를 맞춤.

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <Button
        android:id="@+id/button_1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        android:layout_marginTop="100dp"
        android:text="Button 1" />

    <Button
        android:id="@+id/button_2"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toEndOf="@+id/button_1"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="@+id/button_1"
        android:text="Button 2" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

  ✓  [Button 2]의 너비가 제약에 맞춰짐. [Button 1]의 끝점과 부모 레이아웃의 끝점만큼의 크기를 차지

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


구글 맵 클러스터링 Google Map Clustering 을 사용하면 지도에 나타나는 여러 개의 마커를 묶어서 하나의 그룹으로 표시 할 수 있음. 직방과 같은 앱에서 화면을 줌아웃하면 마커의 수가 숫자로 표시되는 것과 같은 기능


1.  의존성 추가하기

구글 맵 클러스팅을 사용하기 위해서는 부가적으로 maps-utils 라이브러리가 필요
bulid.gradle 에 의존성 추가.

implementation 'com.google.maps.android:android-maps-utils:2.2.3'

 


2.  데이터 클래스에 ClusterItem 상속받기

  📍 각각의 마커에 해당하는 클래스에 ClusterItem을 상속받고 코드를 추가해야 함
  📍 마커에 해당하는 클래스가 Row이기 때문에 Row 클래스에서 ClusterItem을 상속받은 후 반환하는 메서드와 부가 정보를 반환하는 메서드들을 구현

data class Row(
    val ADRES: String,
    val CODE_VALUE: String,
    val FDRM_CLOSE_DATE: String,
    val GU_CODE: String,
    val HMPG_URL: String,
    val LBRRY_NAME: String,
    val LBRRY_SEQ_NO: String,
    val LBRRY_SE_NAME: String,
    val OP_TIME: String,
    val TEL_NO: String,
    val XCNTS: String,
    val YDNTS: String
): ClusterItem {
    override fun getPosition(): LatLng { // 개별 마커가 표시될 좌표 반환
        return LatLng(XCNTS.toDouble(), YDNTS.toDouble())
    }

    override fun getTitle(): String? { // 마커를 클릭했을 때 나타나는 타이틀
        return LBRRY_NAME
    }

    override fun getSnippet(): String? { // 마커를 클릭했을 때 나타나는 서브타이틀
        return ADRES
    }

    override fun hashCode(): Int { // id 에 해당하는 유일한 값을 Int 로 반환
        return LBRRY_SEQ_NO.toInt()
    }
}

 

  ✓  앱이 실행되고 지도에 마커가 표시될 때 안드로이드는 Row 클래스의 getPosition() 메서드를 호출해서 해당 마커의 좌표를 계산

  ✓  특정한 범위 안에 있는 마커들을 묶어서 하나의 마커로 만들고, 몇 개의 마커가 표현되어 있는지 숫자로 표시해 주는 것을 가르켜 클러스터링이라 함

 


3.  클러스터 매니저

클러스터링은 클러스터 매니저 ClusterManager를 통해서 사용할 수 있음
클러스터 매니저를 액티비티에 선언해두고, 마커를 표시하는 코드에서 클러스터 매니저에 추가하는 코드를 작성

 

1)  클러스터 매니저 선언하기

 

  📍  MapsActivity 클래스 안에 clusterManager 프로퍼티를 선언

class MapsActivity : AppCompatActivity(), OnMapReadyCallback {

    private lateinit var mMap: GoogleMap
    private lateinit var binding: ActivityMapsBinding
    private lateinit var clusterManager: ClusterManager<Row>

 

2)  클러스터 매니저 초기화

 

  📍  clusterManager를 초기화하고 필요한 설정을 함. 마커를 표시하기 전에 설정해야 하기 때문에 Map이 생성된 직후인 onMapReady()에서 설정하는 것이 좋음

override fun onMapReady(googleMap: GoogleMap) {
    mMap = googleMap
    
    // 클러스터 매니저 세팅
    clusterManager = ClusterManager(this, mMap)
    mMap.setOnCameraIdleListener(clusterManager) // 화면을 이동 후 멈췄을 때 설정
    mMap.setOnMarkerClickListener(clusterManager) // 마커 클릭 설정
    loadLibrary()
}

 

 

3) 클러스터 매니저에 데이터 추가하기


  📍  반복문에서 마커를 생성하는 코드를 삭제하고 clusterManager에 직접 데이터를 추가

private fun showLibrary(libraryResponse: LibraryResponse) {
    val latLngBounds = LatLngBounds.builder()

    for (lib in libraryResponse.SeoulPublicLibraryInfo.row) {
        // 기존 마커 세팅코드는 삭제하고 클러스터 메니저에 데이터를 추가하는 코드만 넣어줌.
        clusterManager.addItem(lib)

        // 첫 화면에 보여줄 범위를 정하기 위해서 아래 코드 두 줄은 남겨둠.
        val position = LatLng(lib.XCNTS.toDouble(),lib.YDNTS.toDouble())
        latLngBounds.include(position)
    }

    val bounds = latLngBounds.build()
    val padding = 0
    val updated = CameraUpdateFactory.newLatLngBounds(bounds,padding)
    
    mMap.moveCamera(updated)
}

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  프로젝트 생성하고 의존성 추가하기

서울 공공도서관 앱은 지도 정보가 필요하므로 Google Maps Activity를 사용

AndroidManifest.xml
  • API 키 입력
  • 인터넷 접근 권한 추가
  • 도서관 정보 API가 보안 프로토콜인 HTTPS가 아니라 HTTP를 사용하기 때문에 application 태그에 usesCleartextTraffic 속성 추가
<meta-data
    android:name="com.google.android.geo.API_KEY"
    android:value="API_KEY 입력" />
<uses-permission android:name="android.permission.INTERNET"/>
<application
    android:usesCleartextTraffic="true"

 

build.gradle
  • 레트로핏과 viewBinding 설정
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
buildFeatures {
    viewBinding true
}


2.  데이터 클래스 생성

웹 브라우저에 주소를 요청해서 받은 json 샘플 데이터로 Kotlin 데이터 클래스를 생성


1)  레트로핏 관련 파일만 모아둘 패키지를 생성


2)  data 클래스 생성

  • retrofit 패키지를 우클릭 - [New] - [Kotlin data class File from JSON] 을 클릭
  • 복사한 JSON 데이터를 그대로 넣어줌. 클래스의 이름을 LibraryResponse 로 지정
  • [Enable Inner Class Model]가 체크 해제된 것을 확인 


3)  레트로핏 클래스 작성

  • retrofit 패키지에 RetrofitConnection 클래스 생성
  • 레트로핏 객체를 생성하는 getInstance() 메서드 생성
class RetrofitConnection {
    // 객체를 하나만 생성하는 싱글턴 패턴을 적용.
    companion object {
        // API 서버의 주소가 BASE_URL이 돰.
        private const val BASE_URL = "http://openapi.seoul.go.kr:8088/"
        private var INSTANCE: Retrofit? = null

        fun getInstance(): Retrofit {
            if (INSTANCE == null) {
                INSTANCE = Retrofit.Builder()
                    .baseUrl(BASE_URL)
                    .addConverterFactory(GsonConverterFactory.create())
                    .build()
            }
            return INSTANCE!!
        }
    }
}

 

  ✓  컨버퍼 팩토리서버에서 온 JSON 응답을 데이터 클래스 객체로 변환. Gson이라는 레트로핏 기본 컨버터 팩토리를 사용


4)  HTTP 메서드를 정의해놓은 인터페이스 작성


  🚀  HTTP 메서드를 작성해 레트로핏이 데이터를 가져올 수 있도록 작업.
  🚀  인터페이스를 작성하면 레트로핏 라이브러리가 인터페이스에 정의된 API 엔드포인트들을 자동으로 구현


  ①  인터페이스를 추가

      -  LibraryService 인터페이스 생성

 

  ②  인터페이스를 작성
      -  LibraryService 인터페이스를 작성. 필요한 API는 GET 메서드.

interface LibraryService {
    @GET("{apiKey}/json/SeoulPublicLibraryInfo/1/200/")
    fun getLibrary(@Path("apiKey") key: String): Call<LibraryResponse>
}

 


5)  레트로핏으로 데이터 불러오기


  🚀  작업한 인터페이스를 적용하고 데이터를 불러오는 코드를 작성

  ①  MapActivity에 onMapReady() 아래에 loadLibrary() 메서드 작성

override fun onMapReady(googleMap: GoogleMap) {
    mMap = googleMap

    val sydney = LatLng(-34.0, 151.0)
    mMap.addMarker(MarkerOptions().position(sydney).title("Marker in Sydney"))
    mMap.moveCamera(CameraUpdateFactory.newLatLng(sydney))

    loadLibrary()
}
private fun loadLibrary() {
}

 

  ②  정의한 인터페이스를 실행 가능한 객체로 변환

// 레트로핏 객체를 이용하면 LibraryService 인터페이스 구현체를 가져올 수 있음
val retrofitAPI = RetrofitConnection.getInstance().create(LibraryService::class.java)

 

  ③  인터페이스에 정의된 getLibrary() 메서드에 api키를 입력하고, enqueue() 메서드를 호출해서 서버에 요청

private fun loadLibrary() {

    val retrofitAPI = RetrofitConnection.getInstance().create(LibraryService::class.java)
    retrofitAPI.getLibrary("API키").enqueue(object : Callback<LibraryResponse> {
        override fun onResponse(call: Call<LibraryResponse>, response: Response<LibraryResponse>) {
            TODO("Not yet implemented")
        }

        override fun onFailure(call: Call<LibraryResponse>, t: Throwable) {
            TODO("Not yet implemented")
        }
    })
}

 

 

  ④  오버라이드한 메서드를 구현

  • 서버 요청이 실패했을 경우 간단한 토스트 메시지로 알려줌
  • 서버에서 데이터를 정상적으로 받았다면 지도에 마커를 표시하는 메서드를 호출하도록 코드를 추가
private fun loadLibrary() {
    // 레트로핏 객체를 이용하면 LibraryService 인터페이스 구현체를 가져올 수 있음.
    val retrofitAPI = RetrofitConnection.getInstance().create(LibraryService::class.java)
    retrofitAPI.getLibrary("4f666a5957736f6d35367479796e4e").enqueue(object: Callback<LibraryResponse> {
    
        override fun onResponse(
            call: Call<LibraryResponse>, 
            response: Response<LibraryResponse>) { // 지도에 마커 표시
        
            showLibrary(response.body() as LibraryResponse)
        }

        override fun onFailure(call: Call<LibraryResponse>, t: Throwable) {
            Toast.makeText(baseContext, "서버에서 데이터를 가져올 수 없습니다.", Toast.LENGTH_SHORT).show()
        }
    })
}

 


6)  지도에 도서관 마커 표시하기

 

  ①  지도에 마커를 표시하는 showLibrary() 메서드를 loadLibrary() 메서드 아래에 생성

private fun showLibrary(libraryResponse: LibraryResponse) { }

 

  ②  파라미터로 전달된 LibraryResponse의 SeoulPublicLibraryInfo.row에 도서관 목록이 담겨 있음

private fun showLibrary(libraryResponse: LibraryResponse) {
    // 도서관 목록을 반복문으로 하나씩 꺼냄.
    for (lib in libraryResponse.SeoulPublicLibraryInfo.row) {
        // 마커의 좌표를 생성
        val position = LatLng(lib.XCNTS.toDouble(), lib.YDNTS.toDouble())
        // 좌표와 도서관 이름으로 마커를 생성
        val marker = MarkerOptions().position(position).title(lib.LBRRY_NAME)
        // 지도에 마커를 추가
        mMap.addMarker(marker)
    }
}

 

  ③  지도를 보여주는 카메라가 시드니를 가르키므로 카메라의 위치 조정이 필요

  • 수동으로 카메라의 좌표를 직접 입력해 주는 방법도 있지만 마커 전체의 영역을 구하고, 마커의 영역만큼 보여주는 코드를 작성
private fun showLibrary(libraryResponse: LibraryResponse) {
    val latLngBounds = LatLngBounds.builder() // 마커 영역 지정

    // 도서관 목록을 반복문으로 하나씩 꺼냄.
    for (lib in libraryResponse.SeoulPublicLibraryInfo.row) {
        val position = LatLng(lib.XCNTS.toDouble(), lib.YDNTS.toDouble())
        val marker = MarkerOptions().position(position).title(lib.LBRRY_NAME)
        mMap.addMarker(marker)

        latLngBounds.include(marker.position) // 마커 추가
    }

    val bounds = latLngBounds.build() // 저장해 둔 마커의 영역을 구함
    val padding = 0
    // bounds와 padding으로 카메라를 업데이트
    val updated = CameraUpdateFactory.newLatLngBounds(bounds, padding)

    mMap.moveCamera(updated)
}

 


7)  onMapReady에서 loadLibrary() 메서드 호출하기


  🚀  onMapReady()에 기본으로 작성되어 있는 코드를 주석 처리하고 loadLibrary() 메서드를 호출

 override fun onMapReady(googleMap: GoogleMap) {
    mMap = googleMap

//        val sydney = LatLng(-34.0, 151.0)
//        mMap.addMarker(MarkerOptions().position(sydney).title("Marker in Sydney"))
//        mMap.moveCamera(CameraUpdateFactory.newLatLng(sydney))

    loadLibrary()
}

 


8)  도서관 클릭 시 홈페이지로 이동하기


  🚀  클릭리스너로 새 창을 띄우거나 추가적인 처리를 할 수 있음. 여기서는 도서관 홈페이지의 url이 있는지 검사하고, 있으면 홈페이지를 웹 브라우저에 띄우는 코드를 작성

 

  ①  마커에 tag 정보를 추가

  • 마커를 클릭하면 id와 같은 구분 값을 tag에 저장해두고 사용할 수 있음
  • 지도에 마커를 추가하는 코드로 수정하고 tag 값에 홈페이지 주소를 저장
MapsActivity에서 showLibrary() 메서드의 다음 부분을 수정
for (lib in libraryResponse.SeoulPublicLibraryInfo.row) {
    val position = LatLng(lib.XCNTS.toDouble(), lib.YDNTS.toDouble())
    val marker = MarkerOptions().position(position).title(lib.LBRRY_NAME)
    //mMap.addMarker(marker)
    
    val obj = mMap.addMarker(marker)
    obj?.tag = lib.HMPG_URL

    latLngBounds.include(marker.position) // 마커 추가
}

 

  ②  클릭리스너를 달고 tag 홈페이지 주소를 웹 브라우저에 띄움

  • onMapReady() 안에서 추가로 코드를 작성
  • 지도에 마커클릭리스너를 달고 리스너를 통해 전달되는 마커의 tag를 검사해서 값이 있으면 인텐트로 홈페이지를 띄움
mMap.setOnMarkerClickListener {
    if (it.tag != null) {
        var url = it.tag as String
        if (!url.startsWith("http")) {
            url = "http://${url}"
        }
        val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
        startActivity(intent)
    }
    true
}

 

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  레트로핏 데이터 통신 라이브러리

적은 양의 코드로 데이터를 통신 할 수 있게 도와줌. 안드로이드용 레트로핏 라이브러리는 인터페이스를 사용하기 때문에 처음에는 낯설수 있지만, 익숙해지면 네트워크와 관련된 코드가 단순해지고 관리도 쉬워짐. 

 

레트로핏의 공식 사이트
 

Retrofit

A type-safe HTTP client for Android and Java

square.github.io

 


1)  레트로핏을 위한 준비사항

  🚀  레트로핏을 사용하기 전에 두 가지 주의사항이 필요

  • 데이터를 가져올 곳 (웹 사이트 또는 API 서버) 결정
  • 어떤 (표준 프로토콜) 데이터를 사용할 것인지 데이터의 형식을 결정


①  사용자 정보 API를 무료로 제공하는 Githhub API


    ✓  서울시에서 제공하는 '서울 열린데이터광장'의 데이터나 날씨 정보를 제공하는 API 등을 사용할 수도 있지만 우선은 깃 허브에서 공개한 Githhub API를 사용. 깃허브는 개발자를 위해서 가입 없이 무료로 사용할 수 있는 API를 제공

Githhub API : https://developer.github.com/v3/

 

    ✓  예제에서는 Githhub API 중에서 사용자 정보를 검색하고 사용자 정보의 저장소를 보여주는 API를 사용

 

②  간단한 데이터 구조를 가진 JSON

 

    ✓  HTML은 구조가 복잡해서 짧은 시간에 분석하고 처리하기에는 거의 불가능한 수준의 프로토콜
    ✓  그런 이유로 현재 데이터 통신용으로 가장 많이 사용되고 있고 구조 또한 간단한 JSON Javascript Object Notation을 사용
    ✓  Githhub API는 JSON 형식으로 만들어진 데이터를 제공. JSON은 데이터 교환에 사용하는 표준 데이터 형식으로 사람과 컴퓨터가 이해하기 쉬우면서 데이터 용량이 적다는 장점이 있음
    ✓  네트워크 관점에서 JSON은 HTTP와 같은 데이터 프로토콜에서 바디 영역에 정의된 데이터 통신을 위한 개방형 규격

 


2)  JSON의 구조

  🚀  간단한 구조로 되어있지만, 각각의 형식이 의미하는 바를 알고 있어야 함. JSON은 크게 세 가지 형태의 조합으로 구성

  • JSON 오브젝트
  • JSON 데이터
  • JSON 배열


①  JSON 오브젝트


    ✓  JSON 객체는 여는 중괄호 '{'로 시작해 닫는 중괄호 '}'로 끝남

 

②  JSON 데이터


    ✓  JSON 오브젝트인 중괄호 '{}' 사이에 "데이터 이름": 값의 형식으로 표현되며 이름은 항상 쌍따옴표 ""로 감싸야 하고 이름과 값의 사이에는 콜론 ':'으로 구분. 데이터가 여러 개일 경우에는 쉼표 ','로 구분

{ "데이터 이름": "", "데이터2 이름": "값2" }

 

   ✓  데이터의 값은 문자, 숫자, 불린, null, JSON 객체, JSON 배열이 될 수 있는데 표현식은 조금씩 다름

데이터 형식 데이터 이름 : 값 표현 비고
문자 "데이터 이름": "값" 값을 쌍따옴표로 감싸야 함.
숫자 "데이터 이름": 123 값을 쌍따옴표로 사용하지 않음.
boolean "데이터 이름": true true, false를 값으로 사용하되 쌍따옴표를 사용하지 않음.
null "데이터 이름": null null 값을 사용할 수 있음
JSON 객체 "데이터 이름": { } 데이터의 값으로 JSON 오브젝트를 사용할 수 있음.
JSON 배열 "데이터 이름": [ ] 데이터의 값으로 JSON 배열을 사용할 수 있음.

 

 

③  JSON 배열


    ✓  JSON 배열은 JSON 오브젝트의 컬렉션으로 여는 대괄호로 시작해 닫는 대괄호로 끝남
    ✓  배열에 입력되는 JSON 오브젝트가 복수 개일 경우는 쉼표로 구분

[ {"데이터 이름": ""}, {"데이터1 이름": "값1"}, {"데이터2 이름": "값2"} ]

 


3)  깃허브 사용자 정보를 가져오는 앱 개발하기

깃허브에서 가져온 목록 데이터에는 이미지 정보인 아바타 주소가 포함되어 있음.  HttpURLConnection 을 직접 구현해서 서버에 있는 아바타 이미지를 화면에 보여줄 수도 있지만, 구현 난이도는 높은 반면에 효율성은 떨어지므로 라이브러리를 사용

이미지를 화면에 보여주기 위해서는 이미지 로딩 라이브러리를 사용할 수 있는데 이미지가 있는 URL 주소만 알려주면 해당 이미지가 있는 서버에 접속하여 이미지를 다운로드해서 이미지뷰에 보내는 편리한 도구.

현재 라이브러리 중에 많이 사용되고 있는 것으로는 Glide와 피카소가 있으며 여기서는 조금 더 많은 사용자 층을 가지고 있는 Glide를 사용.

 

Glide 홈페이지
https://bumptech.github.io/glide/
https://github.com/bumptech/glide

 


①  Retrofit과 Glide 설정하기

 

    ✓  build.gradle 파일을 열고 viewBinding 설정을 해줌

buildFeatures {
    viewBinding true
}

 

    ✓ dependencies에 레트로핏과 converter-gson 의존성을 추가

          ➡️  converter-gson은 레트로핏에서 JSON 데이터를 사용하기 위해서 사용하는 부가적인 라이브러리

implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'

 

    ✓  dependencies에 Glide 의존성을 추가

implementation 'com.github.bumptech.glide:glide:4.11.0'

 


②  권한 설정하고 데이터 클래스 정의하기


    ✓  인터넷 접근을 위해 AndroidManifest.xml에 인터넷 권한을 추가

<uses-permission android:name="android.permission.INTERNET"/>

 

    ✓  안드로이드 스튜디오는 앱 개발에 도움을 주는 다양한 플러그인을 지원. 그중에서 JSON TO Kotlin Class 플러그인은 JSON 형식으로 된 텍스트 데이터를 코틀린 클래스로 간단하게 변환해 줌

  • 안드로이드 스튜디오의 상단 메뉴에서 [FIle] - [Settings]를 클릭한 후 나오는 세팅 창에서 [Plugin]을 선택한 다음 'JSON To Kotlin Class' 플러그인을 검색하고 설치
  • 브라우저에서 https://api.github.com/users/Kotlin/repos 웹페이지를 열고, 이 페이지의 JSON 데이터를 전체선택 Ctrl + A, 복사 Ctrl + C 키를 연속으로 눌러 데이터를 복사
  • 다시 안드로이드 스튜디오에서 기본 패키지를 마우스 우클릭하고 [New] - [Kotlin data class File from JSON]을 클릭.
  • 새 창이 뜨면 복사한 JSON 데이터를 붙여넣고, [Class Name]에 'Repository'를 입력하고 [Generate] 버튼을 클릭하면 변환된 데이터 클래스를 자동으로 생성.
  • License, Owner, Repository, RepositoryItem 클래스가 생성. License, Owner 클래스는 JSON 데이터가 JSON 오브젝트를 값으로 사용하는 경우, 해당 데이터의 이름으로 클래스를 생성하고 사용

 


③  화면 만들기

activity_main.xml 파일을 편집
  • 깃허브의 데이터 API 주소를 요청할 버튼을 화면 상단에 배치
  • id 속성을 'buttonRequest', text 속성은 'GITHUB 사용자 가져오기'로 입력

  • 가져온 데이터의 목록을 보여줄 리사이클러뷰를 버튼 아래쪽 공간에 배치
  • id 속성 'recyclerView'라고 입력
  • 버튼과 리사이클러뷰의 컨스트레이트를 연결
리사이클러뷰 안에 넣을 아이템을 위한 새 파일을 생성
  • [app] - [res] - [layout] 디렉토리를 마우스 우 클릭하면 나오는 메뉴에서 [New] - [Layout Resource File]을 클릭
  • File name은 'item_recycler'로 생성
  • 레이아웃의 layout_height 속성은 '100dp' 정도로 설정
  • 이미지와 같이 이미지뷰 1개와 텍스트뷰 2개를 배치하고 id 속성을 입력
  • imageAvatar, textName, textId

 


④  리사이클러뷰 어탭터 만들기

  • 사용자 정보를 목록으로 보여주기 위해 리사이클러뷰 어댑터를 생성하고 사용
  • [app] - [java] 디렉토리 밑에 있는 기본 패키지에 CustomAdapter 클래스를 하나 생성
  • 생성된 클래스 파일을 열고 CustomAdapter 클래스 밑에 Holder 클래스를 추가
class CustomAdapter {
}

class Holder {
}

 

  • 홀더의 생성자에 바인딩을 전달하고 상속받은 ViewHolder()에는 binding.root를 전달
class Holder(val binding: ItemRecyclerBinding) : RecyclerView.ViewHolder(binding.root) {
}

 

  • CustomAdapter에 RecyclerView.Adapter를 상속하고 제네릭으로 Holder를 지정
class CustomAdapter: RecyclerView.Adapter<Holder>() {
}

 

  • 3개의 필수 메서드를 자동으로 생성. 함께 생성된 TODO() 행은 모두 삭제
class CustomAdapter: RecyclerView.Adapter<Holder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {

    }

    override fun onBindViewHolder(holder: Holder, position: Int) { 
    
    }

    override fun getItemCount(): Int {

    }
}

 

  • 자동 생성된 코드는 그대로 두고 어댑터 코드 블록 가장 위에 어댑터에서 사용할 데이터 컬렉션을 변수로 만들어 둠
  • 사용할 데이터셋은 앞에서 자동으로 생성해 두었던 Repository이고 nullable로 선언
class CustomAdapter: RecyclerView.Adapter<Holder>() {
    var userList: Repository? = null

 

  • 목록에 출력되는 총 아이템 개수를 정하는 getItemCount()를 구현
override fun getItemCount(): Int {
    return userList?.size?: 0
}

 

  • 홀더를 생성하는 onCreateViewHolder()를 구현. 레이아웃을 인플레이트한 뷰 바인딩에 담아서 반환
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
    val binding = ItemRecyclerBinding.inflate(LayoutInflater.from(parent.context), parent, false)
    return Holder(binding)
}

 

  • 실제 목록에 뿌려지는 아이템을 그려주는 onBindViewHolder()를 구현
  • 현 위치의 사용자 데이터를 userList에서 가져오고 아직 만들어지지 않은 홀더의 setUser()메서드에 넘겨줌
override fun onBindViewHolder(holder: Holder, position: Int) {
    val user = userList?.get((position))
    holder.setUser(user)
}

 

  • 다시 Holder 클래스로 돌아가서 setUser() 메서드를 구현
  • setUser() 메서드는 1개의 RepositoryItem을 파라미터로 사용
  • 클래스의 가장 윗줄에서 userList가 nullable이기 때문에 user 파라미터도 nullable로 설정되어야 함
class Holder(val binding: ItemRecyclerBinding) : ViewHolder(binding.root) {
    fun setUser(user: RepositoryItem?) {

    }
}

 

홀더가 가지고 있는 아이템 레이아웃에 데이터를 하나씩 세팅해주면 되는데 우리가 사용하는 데이터는 세 가지

변수 user: RepositoryItem에 있는 각각의 데이터 이름은 다음과 같음


  ✓  아바타 주소 : user.owner.user.owner.avatar_url
  ✓  사용자 이름 : user.name
  ✓  사용자 ID : user.node_id

  • 먼저 사용자 이름과 아이디를 세팅. 아바타는 Glide를 사용해서 이미지뷰에 세팅.
class Holder(val binding: ItemRecyclerBinding) : ViewHolder(binding.root) {
    fun setUser(user: RepositoryItem?) {
        user?.let {
            binding.textName.text = user.name
            binding.textId.text = user.node_id
            Glide.with(binding.imageAvatar).load(user.owner.avatar_url).into(binding.imageAvatar)
        }
    }
}

 

 


⑤  레트로핏 사용하기

 

레트로핏을 사용해서 데이터를 조회해서 가져오고 어댑터를 통해 목록에 출력.  레트로핏을 사용하기 위해서는 인터페이스가 정의되어 있어야 함

MainActivity.kt를 열고 onCreate() 메서드 위에 바인딩을 생성한 후 binding 프로퍼티에 저장하고
setContentView()에 binding.root를 입력
class MainActivity : AppCompatActivity() {
    private val binding by lazy {
        ActivityMainBinding.inflate(layoutInflater) 
    }
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)
    }
}

 

클래스 아래에 GithhubService 인터페이스 생성
레트로핏 인터페이스는 호출 방식, 주소, 데이터 등을 지정
Retrofit 라이브러리는 인터페이스를 해석해 HTTP 통신을 처리
class MainActivity : AppCompatActivity() {
    private val binding by lazy {
        ActivityMainBinding.inflate(layoutInflater) 
    }
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)
    }
}

interface GithubService {

}

 

인터페이스 안에 Github API를 호출할 users() 메서드를 만들고 @GET 어노테이션을 사용해서 요청 주소를 설정
요청 주소는 Github의 도메인을 제외하고 작성. 반환값은 Call<List<데이터 클래스>> 형태로 작성
레트로핏은 이렇게 만들어진 인터페이스에 지정된 방식으로 서버와 통신하고 데이터를 가져옴
interface GithubService {
    @GET("users/Kotlin/repos")
    fun users(): Call<Repository>
}

 

 

onCreate() 블록 안에서 recyclerView의 adapter에 앞에서 만들었던 CustomAdapter를 생성하고 recyclerView에 연결
val adapter = CustomAdapter()
binding.recyclerView.adapter = adapter

 

리니어 레이아웃 매니저 연결
binding.recyclerView.layoutManager = LinearLayoutManager(this)

 

Retrofit.Builder()를 사용해서 레트로핏을 생성하고 retrofit 변수에 담음


    ✓  baseUrl이 되는 Github의 도메인 주소와 JSON 데이터를 앞에서 생성한 Repository 클래스의 컬렉션으로 변환해주는 컨버터를 입력하고 build() 메서드를 호출해서 생성

val retrofit = Retrofit.Builder()
    .baseUrl("https://api.github.com/")
    .addConverterFactory(GsonConverterFactory.create())
    .build()

 

 

요청 버튼을 클릭하면 앞에서 생성해둔 레트로핏을 이용해 데이터를 불러오고 어댑터에 세팅
buttonRequest에 클릭리스너를 연결
binding.buttonRequest.setOnClickListener {
}

 

레트로핏의 create() 메서드에 앞에서 정의한 인터페이스를 파라미터로 넘겨주면 실행 가능한 서비스 객체를 생성해서 반환
val githubService = retrofit.create(GithubService::class.java)

 

  ✓  githubService에는 GithubService 인터페이스를 이용해서 객체를 생성했기 때문에 실행(호출)가능한 상태의 users() 메서드를 가지고 있음
  ✓  레트로핏의 create() 메서드는 인터페이스를 실행 가능한 서비스 객체로 만들면서 users() 메서드 안에 비동기 통신으로 데이터를 가져오는 enqueue() 메서드를 추가. enqueue()가 호출되면 통신이 시작됨.

binding.buttonRequest.setOnClickListener {
    val githubService = retrofit.create(GithubService::class.java)
    githubService.users().enqueue()
}

 

  ✓  enqueue() 메서드를 호출한 후 Github API 서버로부터 응답을 받으면 enqueue() 안에 작성하는 콜백 인터페이스가 작동하게 됨
  ✓  enqueue()의 파라미터로 콜백 인터페이스를 구현

githubService.users().enqueue(object: Callback<Repository> {

})

 

콜백 인터페이스의 필수 메서드도 구현. 메서드 2개를 선택해서 자동 생성
선택한 메서드와 함께 자동 생성된 TODO() 행은 모두 지움
메서드의 이름에서 유추할 수 있듯이 통신이 성공적이면 onResponse()가 호출
override fun onFailure(call: Call<Repository>, t: Throwable) {

}

override fun onResponse(call: Call<Repository>, response: Response<Repository>) {

}

 

  ✓  onResponse() 메서드의 두 번째 파라미터인 response의 body() 메서드를 호출하면 서버로부터 전송된 데이터를 꺼낼 수 있음
  ✓  꺼낸 데이터를 List<Repository>로 형변환한 후에 어댑터의 userList에 담음
  ✓  마지막으로 어댑터의 notifyDataSetChanged()를 호출하면 리사이클러뷰에 변경된 사항이 반영

override fun onResponse(call: Call<Repository>, response: Response<Repository>) {
    adapter.userList = response.body() as Repository
    adapter.notifyDataSetChanged()
}

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  게시물의 수정/삭제 처리

게시물 수정/삭제에 대한 처리는 GET 방식으로 게시물을 수정이나 삭제할 수 있는 화면을 보는 것으로 시작

BoardController의 read()를 수정해서 /board/modify에도 적용되도록 수정
@GetMapping({"/read", "/modify"})
public void read(Long bno, PageRequestDTO pageRequestDTO, Model model) {
    BoardDTO boardDTO = boardService.readOne(bno);
    log.info(boardDTO);
    model.addAttribute("dto", boardDTO);
}

 

templates의 board 폴더에는 modify.html을 추가
<!DOCTYPE html>
<html lang="en"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      xmlns:th="http://www.thymeleaf.org"
      layout:decorate="~{layout/basic.html}">
<head>
    <meta charset="UTF-8">
    <title>Board Modify</title>
</head>

<div layout:fragment="content">
    <div class="row mt-3">
        <div class="col">
            <div class="card">
                <div class="card-header">
                    Board Modify
                </div>
                <div class="card-body">
                
                </div> <!-- end card-body -->
            </div> <!-- end card -->
        </div> <!-- end col -->
    </div> <!-- end row -->
</div>

 

<form> 태그를 추가하고 'dto'라는 이름으로 전달된 BoardDTO 객체를 출력

 

  📍  <form> 태그에 제목 title, 내용 contents 을 수정할 수 있도록 작성하고 전송할 때 필요한 name 속성을 확인

<div class="card-body">
    <form th:action="@{/board/modify}" method="post" id="f1">
        <div class="input-group mb-3">
            <span class="input-group-text">Bno</span>
            <input type="text" class="form-control" name="bno" th:value="${dto.bno}" readonly>
        </div>

        <div class="input-group mb-3">
            <span class="input-group-text">Title</span>
            <input type="text" class="form-control" name="title" th:value="${dto.title}" >
        </div>

        <div class="input-group mb-3">
            <span class="input-group-text">Content</span>
            <textarea class="form-control col-sm-5" name="content" rows="5" >[[${dto.content}]]</textarea>
        </div>

        <div class="input-group mb-3">
            <span class="input-group-text">Writer</span>
            <input type="text" class="form-control" name="writer" th:value="${dto.writer}" readonly>
        </div>

        <div class="input-group mb-3">
            <span class="input-group-text">RegDate</span>
            <input type="text" class="form-control" th:value="${#temporals.format(dto.regDate, 'yyyy-MM-dd HH:mm:ss')}" readonly>
        </div>
        
        <div class="input-group mb-3">
            <span class="input-group-text">ModDate</span>
            <input type="text" class="form-control" th:value="${#temporals.format(dto.modDate, 'yyyy-MM-dd HH:mm:ss')}" readonly>
        </div>

        <div class="my-4">
            <div class="float-end">
                <button type="button" class="btn btn-primary listBtn">List</button>
                <button type="button" class="btn btn-secondary modBtn">Modify</button>
                <button type="button" class="btn btn-danger removeBtn">Remove</button>
            </div>
        </div>
    </form>
</div> <!-- end card-body -->

 

/board/modify?bno=310 형태로 수정화면 접근


1)  수정 처리

  🚀  실제 수정 작업은 POST 방식으로 처리. 수정 후에는 다시 조회 화면으로 이동해서 수정된 내용을 확인할 수 있도록 구현
  🚀  수정 후 조회 화면으로 이동시에 검색했던 조건들이 해당하지 않을 수 있어서, 수정 후에는 검색 조건 없이 단순 조회 화면으로 이동하도록 구현

BoardController에는 POST 방식의 modify()를 구현
@PostMapping("/modify")
public String modify(PageRequestDTO pageRequestDTO,
                     @Valid BoardDTO boardDTO,
                     BindingResult bindingResult,
                     RedirectAttributes redirectAttributes) {

    log.info("board POST modify..." + boardDTO);

    if (bindingResult.hasErrors()) {
        log.info("has errors......");

        String link = pageRequestDTO.getLink();
        redirectAttributes.addFlashAttribute("errors", bindingResult.getAllErrors());
        redirectAttributes.addAttribute("bno", boardDTO.getBno());
        return "redirect:/board/modify?" + link;
    }

    boardService.modify(boardDTO);
    redirectAttributes.addFlashAttribute("result", "modified");
    redirectAttributes.addAttribute("bno", boardDTO.getBno());
    return "redirect:/board/read";
}

 

  ✓  modify()에는 문제가 발생할 때 'errors'라는 이름으로 다시 수정 페이지로 이동 할 수 있도록 PageRequestDTO의 getLink()를 통해서 기존의 모든 조건을 붙여서 /board/modify'로 이동하게 구성.
  ✓  수정 작업에 이상이 없을 때는 아무런 검색이나 페이징 조건없이 /board/read로 이동

 

modify.html에 문제가 있을 때는 자바스크립트를 이용해서 처리하도록 구성
<script layout:fragment="script" th:inline="javascript">
    const errors = [[${errors}]]
    console.log(errors)

    let errorMsg = ''

    if(errors) {
        for (let i = 0; i < errors.length; i++) {
            errorMsg += `${errors[i].field}은(는) ${errors[i].code} \n`;
        }
        history.replaceState({}, null, null)
        alert(errorMsg);
    }
</script>

 

자바스크립트로 이벤트 처리하는 부분을 작성
    const link = [[${pageRequestDTO.getLink()}]];
    const formObj = document.querySelector("#f1");

    document.querySelector(".modBtn").addEventListener("click", function(e){

        e.preventDefault();
        e.stopPropagation();

        formObj.action = `/board/modify?${link}`;
        formObj.method = 'post';
        formObj.submit();

    }, false);

 

  ✓  수정 처리를 할 때는 나중에 잘못되는 상황을 대비해서 페이지 / 검색 정보도 같이 쿼리 스트링으로 전달

 


2)  삭제 처리

  🚀  BoardController에서 remove()라는 메서드 구성, 삭제 후에는 목록으로 이동하도록 구성

@PostMapping("/remove")
public String remove(Long bno, RedirectAttributes redirectAttributes) {
    log.info("remove()...");

    boardService.remove(bno);
    redirectAttributes.addFlashAttribute("result", "removed");
    return "redirect:/board/list";
}

 

modify.html에는 자바스크립트를 이용해서 /board/remove를 호출하도록 작성
document.querySelector(".removeBtn").addEventListener("click", function(e){

    e.preventDefault();
    e.stopPropagation();

    formObj.action = `/board/remove`;
    formObj.method = 'post';
    formObj.submit();

}, false);

 

  ✓ 정상적으로 삭제가 이루어질 경우 RedirectAttributes를 통해서 'result' 값이 전달되므로 list.html에서는 모달창으로 삭제 처리된 것을 알 수 있음

 

 

목록으로 이동하는 버튼의 이벤트 처리는 페이지/검색 조건을 유지하도록 구성
document.querySelector(".listBtn").addEventListener("click", function(e){
    e.preventDefault();
    e.stopPropagation();

    formObj.reset();
    self.location = `board/list?${link}`;
}, false);

 

 

 

 

[ 내용참고 : IT 학원 강의 ]


1.  조회 처리와 화면 개발

BoardController에 특정한 번호의 게시물을 조회하는 기능을 추가
조회할 때는 PageRequestDTO를 같이 활용해서 나중에 목록 화면으로 이동이 가능하도록 구성

@GetMapping("/read")
public void read(Long bno, PageRequestDTO pageRequestDTO, Model model) {
    BoardDTO boardDTO = boardService.readOne(bno);
    log.info(boardDTO);
    model.addAttribute("dto", boardDTO);
}

 

templates의 board 폴더에는 read.html을 구성
<!DOCTYPE html>
<html lang="en"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      xmlns:th="http://www.thymeleaf.org"
      layout:decorate="~{layout/basic.html}">
<head>
    <meta charset="UTF-8">
    <title>Board Read</title>
</head>

<div layout:fragment="content">
    <div class="row mt-3">
        <div class="col">
            <div class="card">
                <div class="card-header">
                    Board Read
                </div>
                <div class="card-body">
                
                </div><!--end card body-->
            </div> <!-- end card -->
        </div> <!-- end col -->
    </div> <!-- end row -->
</div>

 

브라우저에서 /board/read?bno=310 같이 존재하는 번호로 조회

 

BoardController에서 Model에 'dto'라는 이름으로 전달되는 BoardDTO를 출력하는 부분을 <div class="card-body"> 내에 작성
<div class="card-body">
    <div class="input-group mb-3">
        <span class="input-group-text">Bno</span>
        <input type="text" class="form-control" th:value="${dto.bno}" readonly>
    </div>
    
    <div class="input-group mb-3">
        <span class="input-group-text">Title</span>
        <input type="text" class="form-control" th:value="${dto.title}" readonly>
    </div>

    <div class="input-group mb-3">
        <span class="input-group-text">Content</span>
        <textarea class="form-control col-sm-5" rows="5" readonly>[[${dto.content}]]</textarea>
    </div>

    <div class="input-group mb-3">
         <span class="input-group-text">Writer</span>
         <input type="text" class="form-control" th:value="${dto.writer}" >
    </div>

    <div class="input-group mb-3">
         <span class="input-group-text">RegDate</span>
         <input type="text" class="form-control" th:value="${#temporals.format(dto.regDate, 'yyyy-MM-dd HH:mm:ss')}" readonly>
    </div>
                   
    <div class="input-group mb-3">
         <span class="input-group-text">ModDate</span>
         <input type="text" class="form-control" th:value="${#temporals.format(dto.modDate, 'yyyy-MM-dd HH:mm:ss')}" readonly>
    </div>

    <div class="my-4">
        <div class="float-end">
            <button type="button" class="btn btn-primary">List</button>
            <button type="button" class="btn btn-secondary">Modify</button>
        </div>
    </div>


2.  목록 페이지로 이동

read.html의 마지막 부분에 버튼을 통해서 이동하는 부분이 존재. 이에 대한 처리는 각 버튼을 감싸는 <a>태그를 이용해서 PageRequestDTO의 getLink()를 활용

<div class="my-4">
    <div class="float-end" th:with="link = ${pageRequestDTO.getLink()}">
        <a th:href="|@{/board/list}?${link}|" class="text-decoration-none">
            <button type="button" class="btn btn-primary">List</button>
        </a>
        <a th:href="|@{/board/modify(bno=${dto.bno})}&${link}|" class="text-decoration-none">
            <button type="button" class="btn btn-secondary">Modify</button>
        </a>
    </div>
</div>


1)  목록에서 조회 링크 처리

목록 화면에서 게시물을 선택해 이동하도록 처리
목록을 반복문으로 처리하는 부분에서 th:with를 이용해 PageRequestDTO의 getLink() 결과를 변수로 처리하고 나머지 링크를 완성

<tbody th:with="link = ${pageRequestDTO.getLink()}">
    <tr th:each="dto:${responseDTO.dtoList}">
        <th scope="col">[[${dto.bno}]]</th>
        <td><a th:href="|@{/board/read(bno =${dto.bno})}&${link}|"> [[${dto.title}]] </a></td>
        <td>[[${dto.writer}]]</td>
        <td>[[${#temporals.format(dto.regDate, 'yyyy-MM-dd')}]]</td>
    </tr>
</tbody>

 

  ✓  완성된 후에는 페이지나 검색 조건을 유지된 채로 목록에서 조회, 조회에서 목록으로 이동이 가능

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]

+ Recent posts