결과 미리보기

뷰페이저 미리보기 + 애니메이션

1. 가운데 페이지(currentItem)의 width 설정하기

ViewPager2로 좌우 미리보기를 구현할 때, ViewPager2의 width는 가운데 페이지가 차지하는 너비가 된다. 그럼 좌우 페이지는 어떻게 되는 걸까? 일단 계속 따라가보자. 

<androidx.viewpager2.widget.ViewPager2
    android:id="@+id/view_pager_poster"
    android:layout_width="0dp"
    android:layout_height="0dp"
    android:clipChildren="false"
    app:layout_constraintWidth_percent="0.725"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintHeight_percent="0.45"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@id/tv_hint" />

ConstraintLayout을 사용해서 width를 전체 레이아웃의 72.5%로 설정했다.

2. 좌우에 페이지가 보이도록 설정하기

2-1. clipChildren="false"

ViewPager2 속성 중 clipChildren="false"가 눈에 띈다. 문서에 "Defines whether a child is limited to draw inside of its bounds or not."라고 설명되어 있다. Child가 그려지는 공간을 ViewGroup의 영역 내로 제한할 것인지를 의미한다. 기본 값은 true이며, 제한한다는 뜻이다.

False로 설정하면 ViewPager2의 영역을 넘어 양쪽에 페이지가 그려질 수 있다.

 

(비슷한 속성으로 clipToPadding이 있는데, 자식 뷰의 영역을 ViewGroup의 padding 안쪽으로 제한할 것인지를 묻는다. 기본 값은 true이며, 자식 뷰의 그림자 같은 모서리 효과도 패딩에 의해 잘리게 된다.)

2-2. offscreenPageLimit

clipChildren="false"를 설정해고 어댑터에 데이터를 연결해도 양쪽에 페이지가 보이지 않는다. 왜냐면 ViewPager 하위 View 계층에 child가 하나씩만 유지되기 때문이다. 슬라이드를 하면 다음 View가 계층에 추가되고, 이전 View는 사라진다. (Adapter의 ViewHolder는 남아있다)

 

이럴 때 offscreenPageLimit 속성을 사용한다. offscreenPageLimit는 현재 페이지에서 좌우(상하) 각각 몇 개의 View를 유지할 것인지 결정한다. 화면에 보이지 않더라도 View가 생성되어 ViewPager2 하위 계층에 추가된다.

binding.viewPagerPoster.offscreenPageLimit = 2

현재 페이지를 제외하고 왼쪽에 2개, 오른쪽에 2개의 페이지가 미리 로딩된다.

 

여기까지 하면 이런 화면을 볼 수 있다.

좌우 미리보기

3. 좌우 여백 두기

딱 붙어 있는 게 별로라서 페이지의 좌우에 여백을 두려고 한다. ViewPager의 MarginPageTransformer를 활용한다. 문서에는 아래와 같이 쓰여있다.

Adds space between pages via the ViewPager2.PageTransformer API.
Internally relies on View.setTranslationX and View.setTranslationY.
// ...
public MarginPageTransformer(@Px int marginPx) { /*..*/ }

여백을 두는 API이고, 내부적으로는 translation 속성을 사용한다고 한다. PagerTransformer는 페이지를 슬라이드 할 때마다 동작하는데, 슬라이드 할 때마다 각 View의 좌표를 원래 위치에서 Margin만큼 이동시키는 듯 하다.

binding.viewPagerPoster.setPageTransformer(
	MarginPageTransformer(
		resources.getDimensionPixelOffset(R.dimen.game_poster_margin)
    )
)

4. 확대/축소 애니메이션 적용하기

이번에도 PageTransformer를 사용한다. PageTransformer는 슬라이드 할 때마다 동작하고, 좌우 페이지가 현재 페이지로 슬라이드 되면 확대하고 좌우 페이지로 이동하면 축소해야 하기 때문이다.

MarginPageTransformer도 사용해야 해서, 여러 PageTransformer를 혼합할 수 있는 CompositePageTransformer를 사용한다. 

CompositePageTransformer().also {
    it.addTransformer(
        MarginPageTransformer(resources.getDimensionPixelOffset(R.dimen.game_poster_margin))
    )
    it.addTransformer { eachPageView: View, positionFromCenter: Float ->
        val scale = 1 - abs(positionFromCenter)
        eachPageView.scaleY = 0.85f + 0.15f * scale
    }
}

2번째의 addTransformer 문장이 확대/축소 애니메이션이다.

  • positionFromCenter: 가운데(current) 페이지와의 상대적 위치를 뜻한다. 0이면 가운데 페이지, -1이면 왼쪽으로 페이지 하나만큼, +1이면 오른쪽으로 페이지 하나만큼 떨어져 있음을 의미한다. 따라서, 페이지의 위치가 가운데와 가까워질수록 abs(positionFromCenter) = 0에 가까워진다.
  • val scale = 1 - abs(positionFromCenter): 가운데 페이지에 가까워질수록 1, 멀어질수록 0, 더 멀어지면 음수가 된다
  • eachPageView.scaleY = 0.85f + 0.15f * scale: 가운데 페이지는 1, 좌우 페이지는 0.85의 scale을 갖게 된다. 따라서, 미리 보이는 좌우 페이지는 85%만큼 작게 보이며, 가운데로 슬라이드 될수록 1로 커진다.

5. ViewPager2 최종 코드

<androidx.viewpager2.widget.ViewPager2
            android:id="@+id/view_pager_poster"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:clipChildren="false"
            app:layout_constraintWidth_percent="0.725"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHeight_percent="0.45"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/tv_hint" />

binding.viewPagerPoster.run {
    removeOverScroll()
    adapter = Adapter()
    offscreenPageLimit = 2
    setPageTransformer(buildPageTransformer())
	registerOnPageChangeCallback(onPageChange)
}

private fun buildPageTransformer() = CompositePageTransformer().also {
    it.addTransformer(
        MarginPageTransformer(resources.getDimensionPixelOffset(R.dimen.game_poster_margin))
    )
    it.addTransformer { eachPageView: View, positionFromCenter: Float ->
        val scale = 1 - abs(positionFromCenter)
        eachPageView.scaleY = 0.85f + 0.15f * scale
    }
}

6. 결과

뷰페이저 미리보기 + 애니메이션

 

안녕하세요, 이륙사입니다.

최근에 프로그래머스 과제관에서 연습을 하던 중 문자열로된 url을 Bitmap으로 변환해야 하는 일이 있었습니다. 간단하지만 공유하면 좋을 것 같아서 포스팅하려고 합니다.

1. URL -> InputStream -> Bitmap 변환

URL을 InpustSream으로 바꾸는 부분이 핵심입니다.

방법1) URL.openstream() 사용

private fun convertStringToBitmap(urlString: URL): Bitmap {
    val inputStream = url.openStream()
    return BitmapFactory.decodeStream(inputStream) // Bitmap
}

방법2) HttpURLConnection 사용

private fun convertUrlToBitmap(url: URL): Bitmap {
    val connection = url.openConnection() as HttpURLConnection
    connection.doInput = true
    connection.connect()

    return BitmapFactory.decodeStream(connection.inputStream)
}

2. ImageView에 적용

fun setImageFromUrl(imageView: ImageView, urlString: String) {
    val url = URL(urlString)
    val bitmap = convertUrlToBitmap(url)
    imageView.setImageBitmap(bitmap)
}

이렇게 하면.. 짠! NetworkOnMainThreadException가 발생합니다. url의 stream을 여는 과정에서 네트워크가 사용되는데, 이것을 메인 스레드에서 동작시켰기 때문입니다. 저는 코루틴을 통해 백그라운드 스레드에서 동작시켜 이를 해결하겠습니다.

fun setImageFromUrl(imageView: ImageView, urlString: String) {
   GlobalScope.launch(Dispatchers.IO) { // 백그라운드 스레드
      val url = URL(urlString)
      val bitmap = convertUrlToBitmap(url)
      imageView.setImageBitmap(bitmap)
   }
}

그리고 동작시켜보면 또 에러가 납니다..ㅋㅋㅋ imageView를 백그라운드 스레드에서 변경하려고 했기 때문이죠. 이것만 메인 스레드에서 동작시키면 정말로 끝이 납니다.

fun setImageFromUrl(imageView: ImageView, urlString: String) {
   GlobalScope.launch(Dispatchers.IO) {
      val url = URL(urlString)
      val bitmap = convertUrlToBitmap(url) // 네트워크는 백그라운드 스레드에서,
      withContext(Dispatchers.Main){ // UI는 메인 스레드에서
         imageView.setImageBitmap(bitmap)
      }
   }
}

최종 코드

GlobalScope.launch(Dispatchers.IO) {
    try {
        val url = URL(urlString)
        val bitmap = convertUrlToBitmap(url)

        withContext(Dispatchers.Main) {
            completed(bitmap)
        }
    } catch (e: Exception) {
        // 에러 처리
    }
}

 

안드로이드

리눅스 기반의 오픈소스 모바일 운영체제

Java와 Kotlin이 호환되는 이유

두 소스 코드 모두 Java 컴파일러와 Kotlin 컴파일러에 의해 .class파일(바이트 코드)로 변환되기 때문이다.

안드로이드에서 코틀린 컴파일 과정

Kotlin 파일 안에 Java 코드가 있을 경우, Java 컴파일러가 컴파일된 Kotlin 코드의 클래스 패스에 Java 코드를 컴파일한다.

안드로이드 컴파일 과정

  1. Java or Kotlin 파일을 바이트 코드로 변환
  2. 바이트 코드와 컴파일된 리소스 파일을 묶어서 dex파일로 변환(컴파일)
  3. ART에서 dex 파일을 실행 (💡 ART(Android Run Time): JVM처럼 dex 파일을 실행시켜주는 런타임 라이브러리)

Minimum sdk

앱이 최소로 서비스하는 API level(안드로이드 버전)을 의미한다. 예를 들어, min sdk가 21이면 API level 20 이하는 해당 앱을 설치할 수 없다. 왜냐면 그 이하에서는 지원하지 않는 API를 사용하기 때문이다.

프로젝트와 모듈

프로젝트 안에는 여러 모듈이 들어갈 수 있다. 안드로이드 프로젝트를 처음 시작하면 자동으로 프로젝트 이름으로 된 폴더와 그 안에 app이라는 폴더가 생성되는데, 이 app이 모듈을 의미한다. app외에도 추가적으로 모듈을 생성할 수 있다.

 

모듈이 여러 개면 모듈 수준의 gradle 파일도 여러 개가 되는 건가??

build.gradle 파일

Gradle 빌드 툴이 프로젝트를 빌드하기 위해 참조하는 파일이다. 빌드 환경 설정 파일이라고 볼 수 있다.

참고

https://sesac.seoul.kr/course/active/detail.do

https://stonybean.github.io/Kotlin-and-Java-compatible/

https://diqmwl-programming.tistory.com/115

+ Recent posts