쾌락코딩

DialogFragment의 size(width, height)조절

|

마음처럼 되지 않는 DialogFragment

액티비티나 프레그먼트 위에서 DialogFragment를 띄우다 보면 DialgFragment의 전체 크기 조절이 마음처럼 되지 않는다.

어느 화면에서나 왼쪽 이미지정도의 사이드 여백을 갖는 Dialog를 만들고 싶지만, Dialog안의 내용물에 따라서 자동으로 오른쪽 이미지처럼 랜더링된다.

여백캡처

해당 DialogFragment의 xml 최상단 뷰를 보면 분명히 android:layout_width="match_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="wrap_content"
        android:background="@drawable/white_border">

        ...
</androidx.constraintlayout.widget.ConstraintLayout>

컨텐츠와 무관하게 이상적인 여백을 가지는 방법

1. 지정해둔 dp 크기로 setLayout 하는 방법

첫 번째 방법은 미리 지정해둔 dp 크기를 불러와 코드상에서 dialog의 width, heigth를 지정해주는 것이다. 구글링을 해보면 stackOverflow에 가장 먼저 나오는 방법. 코드는 아래와 같다.

// .java
// onResume()
int width = getResources().getDimensionPixelSize(R.dimen.popup_width);
int height = getResources().getDimensionPixelSize(R.dimen.popup_height);
getDialog().getWindow().setLayout(width, height);

// .xml
android:layout_width="match_parent"
android:layout_height="match_parent"

이 방법은 Dialog의 너비와 높이를 미리 지정해둔 Dp크기로 설정할 수 있지만, 어느 디바이스에서든 동일한 비율의 여백을 보장할 수는 없다. 또한 앱의 여러곳에서 다른 정보를 가지는 DialogFragment를 사용할 경우, 높이는 내용물에 무관하게 wrap_content하도록 구현하고 싶은 경우가 있지만 위 방법은 꼭 heigth를 지정해줘야 한다.

나에겐 맞지 않는 방법이였다.

2. 디바이스 크기를 구해서 setAtributes 하는 방법

flutter에는 아래 코드로 쉽게 디바이스의 넓이를 구해 알맞은 비율로 레이아웃을 구성하는 경우 많다.

final deviceWidth = MediaQuery.of(context).size.width;

플루터처럼 여백 비율을 정해서 dialogFragment에 반영해보자.

device크기 구하기

android에서는 아래와 같이 디바이스의 크기를 구할 수 있다.

// 꼭 DialogFragment 클래스에서 선언하지 않아도 된다.
val windowManager = this.getSystemService(Context.WINDOW_SERVICE) as WindowManager
val display = windowManager.defaultDisplay
val size = Point()
display.getSize(size)

size.x // 디바이스 가로 길이
size.y // 디바이스 세로 길이

여백 비율로 DialogFragment 크기 조절

DialogFragment의 onResume()안에서 실행해야 한다.

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

// .xml
android:layout_width="match_parent"
android:layout_height="wrap_content"

위와 같이 선언하면 dialogFragment의 너비는 항상 전체 디바이스 너비의 90%가 되며, 높이는 컨텐츠에 따라 자동 조절된다.

Dart variable(다트 변수)

|

변수 선언

변수를 선언할 때 사용하는 키워드는 아래와 같다.

  • var : 변수(mutable)
  • final : 변하지 않는 값(immutable + 런타임 선언 가능)
  • const : 변하지 않는 값(imutable + 컴파일 타임 선언)
  • dynamic : 자바로 치면 Object
  • 각종 타입들

다트(Dart)는 Type Safe한 언어이지만, 코틀린과 동일하게 타입 추론이 가능한 언어이므로 선언시에 타입을 명시하지 않아도 된다.

void main() {
    var a = 1;
    final b = 2;
    cosnt c = 3;
    dynamic d = 4;
    d = 'four';
    String e = 'five'; // 타입 명시
}

finalconst의 차이점에 대한 내용은 Dart: final 과 const를 참고하자.

Type

언어에서 기본적으로 제공하는 타입은 아래와 같다.

  • Number
  • String
  • Booleans
  • List
  • Set
  • Map
  • Runes
  • Symbols

몇 가지 살펴볼만한 타입들을 알아보자.

Number

Dart의 Number에는 intdouble이 있다. 둘 모두 num이라는 추상 클래스를 상속 받았다. 한 가지 주의해야할 것은, doubleint형 값을 넣으려고 할 경우 자동 형변환을 해주지 않는다는 점이다.

void main(){
    int a = 1;
    double b = a; // 자바에선 가능하지만 다트에선 불가능
}

명시적으로 형변환을 해주거나, daouble 타입을 num으로 해주어야 한다.

void main(){
    // 명시적 타입 캐스팅
    int a1 = 1;
    double b1 = a1 as double;

    // num 타입으로 받기
    int a2= 1;
    num b2 = a;
}

String

문자열을 선언 할 때는, single qoute, double qoute를 구분하지 않는다.

void main() {
    final str1 = 'hello';    // single qoute
    final str2 = "hello2";   // double qoute
}

modern programming language답게 문자열 템플릿을 제공한다.

void main() {
    final myName = 'yongjun';
    final strTmeplate = 'Hello, My name is ${yongjun}';
}

List & Set & Map

void main() {
    // List
    var myList1 = [1, 2, 3, 4, 5];
    List<String> myList2 = ['one', 'two', 'three', 'four', 'five'];
    var myList3 = <String>['one', 'two', 'three', 'four', 'five'];

    // Set
    var set1 = {1, 2, 3};
    Set<String> set2 = Set<String>();

    // Map
    var map2 = {
        'key1' : 'myValue1',
        'key2' : 'myValue2,
    };
}

Android Rotate ImageView 90 degree whenever you touch 2 (이미지 클릭마다 에니메이션과 함께 90도씩 회전시키기)

|

이전 포스트에서는 사용자가 버튼을 클릭할 때 마다 이미지 뷰를 애니메이션과 함께 회전시키는 방법을 설명했다. 이미지 용량이 크더라도 버벅임 없이 자연스럾게 회전할 수 있는 방법이었다.

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    myImageView.setOnClickListener {
        val currentDegree = it.rotation
        ObjectAnimator.ofFloat(it, View.ROTATION, currentDegree, currentDegree + 90f)
            .setDuration(300)
            .start()
	}
}

그러나 위의 코드는 한가지 약점이 있다. 가로 세로 길이가 같을 경우 전혀 이상할 것이 없지만 둘 중 하나의 비율이 더 길 경우, 예를 들어 세로 길이가 더 길 경우 90도 회전을 하면 이미지가 일부분 짤리게 되는 현상이 발생한다.

이번 포스팅에서는 카카오톡 사진 전송시 이미지 편집 화면처럼 화면 회전시 자연스럽게 비율 조정까지 하는 방법을 알아보자.

이미지회전GIF

Coil

본격적인 코드를 보기에 앞서 한 가지 알아두어야 할 것이 있는데, 바로 Coil이다. Coil은 코틀린 코루틴으로 작동하는 가벼운 image loading 라이브러리이며 기타 설정 dsl을 지원하고있다. 코루틴을 사용하지만 라이브러리 자체에 코루틴이 내장되어있으므로 따로 코루틴을 설치할 필요는 없다.

// 예시
image.load(
    "https://www.91-img.com/pictures/133188-v4-oppo-f11-mobile-phone-large-1.jpg"
) {
    crossfade(true)
    placeholder(R.drawable.image)
}

1. 이미지뷰와 회전 버튼

가장 먼저 이미지를 띄울 ImageView와 화면 회전을 위한 Button이 필요하다. 이 과정은 다들 아실거라 생각하고 생략한다.

2. 디바이스 전체 크기 구하기

화면을 얼마든지 회전시키더라도 이미지의 가로 혹은 세로 길이 중에서 더 긴 부분이 무조건 화면안에 꽉 차야한다. 짤리는 부분이 없어야하기 때문에 미리 디바이스의 가로, 세로 길이를 구해놓고 이미지 회전 마다 이를 이용해야 한다. 예를 들어 세로가 더 긴 이미지에서 오른쪽으로 90도 회전 시킬 경우 이미지뷰의 세로 높이를 디바이스 가로 길이로 서서히 맞추면서 동시에 회전시킬 것이다.

디바이스의 가로 세로 pixel을 구하는 코드는 아래와 같다.

...
val displayMetrics = DisplayMetrics()
windowManager.defaultDisplay.getMetrics(displayMetrics)

val deviceWidth = displayMetrics.widthPixels
val deviceHeight = displayMetrics.heightPixels
...

3. 버튼 클릭시 회전시키기

해당 부분은 코드부터 보자.

button.setOnClickListener {
    val currentRotation = imageView.rotation // 이미지뷰가 회전되어있는 각도가 몇 인가
    val currentImageViewHeight = imageView.height

    //  gap = 목표 길이 - 현재 길이
    val heightGap = if (currentImageViewHeight > deviceWidth) {
        deviceWidth - currentImageViewHeight
    } else {
        deviceHeight - currentImageViewHeight
    }

    if (currentRotation % 90 == 0.toFloat()) { // 애니메이션 도중 중복 클릭 방지
        // 현재길이를 시작으로 gap만큼 더해준다.
        ValueAnimator.ofFloat(0f, 1f).apply {
            duration = 500
            addUpdateListener {
                val animatedValue = it.animatedValue as Float
                imageView.run {
                    layoutParams.height =
                        currentImageViewHeight + (heightGap * animatedValue)
                            .toInt()
                    rotation = currentRotation + 90 * animatedValue
                    requestLayout()
                }
            }
        }.start()
    }
}

핵심은 heightGap이다. 우리는 이미지뷰의 height를 기준으로 기능을 동작시킬 것이다. 세로가 더 긴 이미지에서 90도 회전 시킬 경우, 세로 길이를 디바이스의 가로 길이만큼으로 줄여야 하는데, 이미지의 세로(높이)가 100이고 디바이스 가로(너비) 길이가 70이라고 한다면 이미지의 높이를 30만큼 줄여나가야 한다. 목표하는 길이 까지의 gap은 -30인 것이다.

이와 반대로 옆으로 길게 누워있는 이미지라면, 90도 회전 시 imageView의 길이가 디바이스의 높이와 같게 늘어나야 한다. 이 상황은 옆으로 누워있는 이미지 뷰의 높이(70)가 디바이스의 가로 길이(70)와 동일할 것이므로, 회전과 함께 이미지뷰의 길이(70)를 디바이스 세로 길이(100)로 만들어 주어야 한다. 즉, 누워있는 이미지뷰의 높이(70)와 회전 이후 목표 길이(100)인 deviceHeight의 차이가 gap이 된다. 목표하는 길이 까지의 gap은 +30이다.

따라서 최종 식은 아래와 같아야 한다.

imageView.height = imageView.height + gap

gap을 0부터 서서히 30까지 만들면 자연스러운 애니메이션이 될 것이다.

회전 애니메이션과 함께 현재 이미지의 높이를 시작으로, gap만큼 크기를 변경시킬 것이다. gap은 위에서 말했듯이 상황에 따라 +일수도 -일수도 있다.

ValueAnimator

ValueAnimator를 사용해서 애니메이션을 적용했다. ValueAnimator.ofFloat(0f, 1f)를 사용하면 원하는 시간(duration)동안 0f 부터 1f까지 서서히 변하는 값을 얻을 수가 있다. 0f부터 시작해서 최종적으로 1f가 될 때 이미지는 90도 회전이 되어있을 것이고, 이미지뷰의 높이는 원하는 길이만큼 조정되어있을 것이다.

전체 코드는 git Gist에서 확인 할 수 있다.

마무리

위 코드는 실제 bitmap이 변경되지 않는다. bitmap연산의 경우 많은 비용이 들기 때문에 사용자의 편의성을 위해 imageView만 회전시킨 것이다. 비트맵 자체를 회전시키는 코드는 간단하지만 자연스러운 애니메이션을 원한다면 결국 imageView에 애니메이션을 걸어주는 코드도 반드시 필요하다.

앞선 포스트에서도 설명했지만, 비트맵 연산이 필요한 경우라면 사용자가 저장 버튼을 누르거나, 화면을 나갈 때 등등 필요한 경우에만 해야한다고 생각한다. 필자의 경우에는 사용자가 회전 버튼을 누를때 마다, 각 이미지가 몇 번 회전 되었는지만 저장해놓고, 필요한 순간에만 (사용자가 버튼을 누른 횟수 * 90도)를 계산하여 비트맵을 조작하여 성능과 사용자 경험에 큰 이득을 얻었다.

Android Rotate ImageView 90 degree whenever you touch (이미지 클릭마다 에니메이션과 함께 90도씩 회전시키기)

|

회전시키고 싶은 이미지 뷰, 또는 특정 버튼 클릭시 90도씩 imageView를 회전시키는 방법.

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    myImageView.setOnClickListener {
        val currentDegree = it.rotation
        ObjectAnimator.ofFloat(it, View.ROTATION, currentDegree, currentDegree + 90f)
            .setDuration(300)
            .start()
	}
}

OjbectAnimator.ofFloat메서드의 두 번째 인자로 시작 degree를, 세 번째 인자로 목표 degree를 넣는다. 시작 degree를 항상 imageView의 rotation값을 넣어주고, 목표 degree에 시작 degree + 90f를 해주면 클릭마다 이미지가 연속적으로 회전된다.

물론 위 코드는 imageView만 돌아갈 뿐 실제 Bitmap이 변경 되는것은 아니기 때문에 회전된 모습 그대로 서버에 전송하고 싶을 경우에는 실제 이미지 데이터인 Bitmap을 변경시키는 추가적인 코드가 필요하다.

실제 Bitmap자체를 회전할 경우 Bitmap.createBitmap이라는 비싼 연산을 수행해야 하는데, 실험 결과 3MB가 넘는 이미지를 회전하는데 드는 비용은 갤럭시s8 기준으로 상당히 느렸다. 따라서 이미지 에디터를 만들거나 뷰어 구현의 경우 매번 실제로 Bitmap을 조작하지 말고 사용자가 보고있는 현재 imageView만 회전 시키는게 좋다. 대신, 해당 이미지뷰가 회전됨에 따라서 몇 도 만큼 회전되었는지를 기록하고 있다가, 사용자가 전송 버튼을 눌렀을 때 Bitmap을 기록해둔 각도 만큼 회전시켜 전송하는게 좋다.

다음 포스트에서는 위의 샘플 코드를 보완하여 가로 세로 비율이 맞지 않는 이미지를 자연스럽게 회전시키는 방법을 공유하려한다.

Android Serializable vs Parcelable

|

안드로이드 개발을 하다보면 액티비티간 데이터를 공유해야 할 경우가 종종 생긴다. 이런 경우를 위해 우리는 intent를 하나 생성하고, 거기에 putExtra()로 데이터를 넣은 후 startActivity(intent)로 데이터를 공유함과 동시에 새로운 액티비티를 띄운다. String이나 Int등 Primitive Type은 그렇다 치더라도 직접 만든 커스텀 객체의 경우에는 그 객체를 Serializable(직렬화가능)하거나 Parcelable(포장가능?)한 객체로 만들어야만 보낼 수 있다.

직렬화란?

직렬화(serialization)란, 객체를 저장 장치에 저장 혹은 네트워크 전송을 위해 텍스트나 이진 형식으로 변환하는 것이다.

Person("JUN", 26) --> 직렬화 --> {"name":"JUN", "age":"26"}

이와 반대로 역직렬화(deserialization)란 텍스트나 이진 형식으로 저장된 데이터로부터 원래의 객체를 만들어내는 것이다.

{"name":"JUN", "age":"26"} --> 역직렬화 --> Person("JUN", 26)

액티비티간 데이터 전달

액티비티간의 데이터 전달이 일반적인 객체와 객체간의 데이터 공유(함수의 파라미터로 넘긴다던가 생성자로 데이터를 넘기는 방식)와 다른 방식으로 동작하는 이유는 무엇일까? 내가 만튼 커스텀 객체의 경우, 객체의 주소값을 넘기지 않고 꼭 값을 직렬화 해서 보내는 이유는 뭘까?

액티비티는 단순히 하나의 앱에서만 데이터를 공유하기도 하지만, 다른 앱, 프로세스들과도 데이터를 공유할 수 있기 때문이다. 개인적으로 만든 앱에서, 카메라 Activity를 시작하는 것이 하나의 예다. 만약 하나의 프로세스에서 다른 프로세스로 객체의 주소값을 넘긴다면 프로세스간에 의존성이 생겨버려 위험하다. 따라서 커스텀 객체를 넘길 때도 꼭 그 값을 넘겨야만 한다.

Serializable

Serializable은 표준 JAVA 인터페이스다.

Serializable를 사용해서 액티비티간 데이터를 공유하는 방법은 쉽다. 클래스가 Serializable를 구현하도록 해놓으면 끝이다. 이렇게 하면 기본적인 방법으로 Serializable를 사용하는 것이다.

package de.vogella.java.serilization;

import java.io.Serializable;

public class MySerializable implements Serializable {
    private int mData;

    public Person(int mData) {
        this.mData = mData;
    }

    public int describeContents() {
         return 0;
     }
}

간단하게 구현할 수 있지만, 그말은 시스템이 개발자 모르게 해주는 작업이 많다는 의미다. Serializable는 내부적으로 reflection을 사용하기 때문에, 프로세스 동작 중에 많은 객체들이 추가로 생성, 사용되고, 사용이 끝난 후에는 가비지 컬렉터가 열일하게 되어 앱의 성능이 낮아진다고 한다.

Parcelable

Parcelable은 안드로이드 SDK가 포함하고 있는 인터페이스다. Parcelable은 Serializable이 했던 Reflection을 사용하지 않도록 설계되어 있기 때문에 그만큼 개발자가 수동적으로 해줘야 할 작업들이 있다.

 public class MyParcelable implements Parcelable {
     private int mData;

     public int describeContents() {
         return 0;
     }

     public void writeToParcel(Parcel out, int flags) {
         out.writeInt(mData);
     }

     public static final Parcelable.Creator<MyParcelable> CREATOR
             = new Parcelable.Creator<MyParcelable>() {
         public MyParcelable createFromParcel(Parcel in) {
             return new MyParcelable(in);
         }

         public MyParcelable[] newArray(int size) {
             return new MyParcelable[size];
         }
     };

     private MyParcelable(Parcel in) {
         mData = in.readInt();
     }
 }

Serializable과는 달리 꼭 구현해야 하는 필수 메서드들이 존재한다. 이 필수 메서드들을 작성하게 되면 액티비티로 데이터를 넘길 자격이 주어진다.

Parcelable을 구현하는 과정이 귀찮아 보이지만, kotlin을 사용중이라면 귀찮은 작업이 줄어든다. kotlin-android-extensions플러그인을 사용하면 @Parcelize를 사용하여 아래와 같은 코드로 구현할 수 있다.

@Parcelize
class MyParcelable(
    private val mData: Int
): Parcelable {
     public int describeContents() {
         return 0;
     }
}

한 가지 주의해야 할 점은, 위와 같은 코드 작성시 주 생성자에 있는 데이터들만 직렬화가 된다는 점이다. 주생성자 데이터 외의 다른 데이터까지 직렬화 하고싶다면, Parcelable 적용 블로그 글을 참조하자.

결론

Serializable와 Parcelable간에는 성능과 구현의 편리함이라는 trade off가 있다. 일부 글에서는 Serializable에서 직렬화 프로세스를 직접 구현하면 Parcelable의 성능을 뛰어 넘기에 성능과 편리함 모두 승리한다는 글이 있다. 그러나 @Serialize를 직접 구현하더라도 둘 사이의 성능 차이는 유의미하지 않는 것으로 보여지기 때문에, 상황에 따라 주생성자에 대부분의 데이터가 존재하는 객체는 @Parcelize를 사용하는 것도 좋을 것 같다. 상황에 따라 개발자 경험을 더 높여 줄 수 있다고 판단되는 것을 잘 선택하자.