이러한 Target를 상속받는 클래스를 만들어서 placeholder는 onLoadStarted, error는 onLoadFailed에 각각 정의하려고 한다. 이때 로딩이미지는 drawable 리소스로 앱에 저장되어있기 때문에 DrawableImageViewTarget을 상속받아 만든 게 아래와 같다.
@Override publicvoidonResourceReady(@NonNull Drawable resource, @Nullable Transition<? super Drawable> transition){ ImageView imageView = getView(); imageView.setImageResource(0); // 로딩 이미지가 보여지고 있을 때 scaleType이 바뀌면서 이미지가 덜그럭 거리는 이슈 방어 imageView.setScaleType(mReadyScaleType); super.onResourceReady(resource, transition); }
그러나 이 방식을 적용한 ImageView가 담긴 RecyclerView를 구현하게 되면, RecyclerView 각 아이템이 재사용되면서 ScaleLoadingImageViewTarget의 생성자에서 호출하는 view.getScaleType()의 값이 기대한 대로 나오지 않는 경우가 있어 문제가 될 수 있다.
현재 한 Activity가 상하 스크롤이 되는 RecyclerView로 구성되어 있고, 그 아이템 중 하나가 좌우 스크롤 RecyclerView를 가지고 있다.
RecyclerView는 View를 재사용하기 때문에 item1, item2, …의 뷰를 그릴 때 item1에서 사용한 뷰를 n번째 item에서 재사용할 수 있다. 따라서 다시 item1을 그릴 때 View를 초기화하게 되므로 item 내의 RecyclerView 스크롤이 초기화된다.
즉, item1의 좌우 스크롤을 움직인 후 아래로 스크롤링하고 item1 위치로 다시 돌아왔을 때 그 좌우 스크롤이 처음으로 되돌아가있는 이슈가 발견되었다.
2. 해결
1 2 3 4
val pos = (rv.layoutManager as LinearLayoutManager?)?.findFirstCompletelyVisibleItemPosition() ?: -1 if (pos != -1 && scrollInfo?.scrollPos != pos) { scrollInfo?.scrollPos = pos }
이렇게 하면 일단 현재 보고있는 item의 position을 얻어와 저장할 수 있다. 이 동작을 사용하여 아래와 같이 OnScrollListener에서 scrollPos와 scrollOffset을 저장한다.
scrollOffset은 scrollPos만으로는 해당 item의 맨 앞부분으로 스크롤이 움직이기 때문에 이를 현재 스크롤하던 위치로 옮겨주기 위함이다.
그리고 저장한 scrollOffset을 사용하여 스크롤을 유지할 수 있는 함수는 아래와 같이 구성하였다.
1 2 3 4 5 6 7 8 9 10
privatefunkeepScroll(scrollInfo: ScrollInfo) { var offset = 0 if (scrollInfo.scrollPos < goodsList.size - 2) { offset = scrollInfo.scrollOffset while (offset > itemWidth) { offset -= itemWidth } } (rv.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(scrollInfo.scrollPos, offset) }
3. 번외
scrollToPositionWithOffset이 position과 offset 값을 받는 걸 이용하여, position은 0으로 고정하고 offset을 rv.computeHorizontalScrollOffset()을 저장한 값으로 넘겨주면 될 거라고 생각했으나 기대한대로 동작하지 않았다.
1
(rv.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(0, offset)
이 글은 실제로는 2021.01에 작성되어 일부 업데이트된 내용이 누락되었을 수 있음을 미리 밝힙니다.
1. 내부 저장소와 외부 저장소
내부 저장소에 저장된 파일은 애플리케이션 전용이며 다른 애플리케이션에서는 액세스 할 수 없다. 내부 저장소는 /data/data/com.example.testapp/ 이와 같은 경로를 말한다.
외부 저장소는 내부 저장소(UT) 또는 이동식 저장소의 어느 위치에나 있을 수 있다. 휴대전화의 OEM 및 Android OS 버전에 따라 달라질 수 있으므로 단말 기종마다 저장되는 위치가 다르다.
/sdcard 및 /storage/emulated/0 와 같은 형식의 패스는 외부 저장소를 가리킨다. 그러나 이것들은 /storage/sdcard0 대한 심볼릭 링크이기 때문에 (예전에는 된 것 같지만) 현재는 이와 같은 패스를 하드코딩하여 사용하지 않는 게 좋다.
2. 접근 권한
READ_EXTERNAL_STORAGE
WRITE_EXTERNAL_STORAGE
접근 권한은 위와 같이 잘 알려진 두 가지가 있으나 Android 11부터 WRITE_EXTERNAL_STORAGE만 권한허용을 해도 READ_EXTERNAL_STORAGE 권한은 자동으로 부여되도록 변경되었다.
Any app that declares the WRITE_EXTERNAL_STORAGE permission is implicitly granted this permission. 역> WRITE_EXTERNAL_STORAGE 권한을 허용한 앱은 내재적으로 READ_EXTERNAL_STORAGE 권한도 허용한다.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { SimpleDateFormat format1 = new SimpleDateFormat("yyyy-MM-dd_HH:mm:ss"); String fileName = format1.format(System.currentTimeMillis()) + ".jpg";
ContentValues values = new ContentValues(); // DCIM/ 또는 Pictures/ 이외의 장소에 접근하려고 하면 오류 values.put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/" + CommConstants.FOLDER_DIRECTORY); values.put(MediaStore.Images.Media.DISPLAY_NAME, fileName); values.put(MediaStore.Images.Media.MIME_TYPE, "image/*"); values.put(MediaStore.Images.Media.IS_PENDING, 1); // 파일을 write중이라면 다른곳에서 데이터요구를 무시
if (imageFile != null) { values.clear(); values.put(MediaStore.Images.Media.IS_PENDING, 0); // 파일을 모두 write하고 다른곳에서 사용할 수 있도록 0으로 업데이트 contentResolver.update(item, values, null, null); } }
(3) MediaStore.MediaColumns.DATA 칼럼 접근 불가
query issue: invalid column latitude
MediaStore.MediaColumns.DATA 칼럼이 android 10부터 deprecated되어 접근할 수 없어서 발생하는 이슈이다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
publicstatic String getRealPathFromURI(Context context, Uri contentUri)throws SecurityException { String res = null; String[] proj = {MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.DISPLAY_NAME}; Cursor cursor = context.getContentResolver().query(contentUri, proj, null, null, null); if (cursor != null && cursor.moveToFirst()) { int columnIndex = (contentUri.toString().startsWith("content://com.google.android.gallery3d")) ? cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME) : cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA); res = cursor.getString(columnIndex); cursor.close(); } if (TextUtils.isEmpty(res)) { res = contentUri.getPath(); } return res; }
따라서 이미지는 Q 미만일 때 위의 코드를 그대로 사용하면서, 비디오를 업로드할 경우는 아래의 코드를 통해 새로 카피본을 만들어 File을 받아 처리하도록 한다.
이미지의 경우, 사진 및 스크린샷을 포함하며 DCIM/ 및 Pictures/ 디렉터리에 저장된다. 시스템은 이러한 파일을 MediaStore.Images 테이블에 추가한다. 즉, 파일 탐색기에서 Images 영역에 가면 저장한 파일을 확인할 수 있다.
만약 DCIM/ 또는 Pictures/ 이외의 장소에 접근하려고 하면 아래와 같은 에러가 뜬다.
Caused by: java.lang.IllegalArgumentException: Primary directory TestDir not allowed for content://media/external_primary/images/media; allowed directories are [DCIM, Pictures]
Parcelable protocol requires a Parcelable.Creator object called CREATOR
1 2 3 4 5 6 7 8 9 10 11
publicstaticfinal Creator<DataClass> CREATOR = new Creator<DataClass>() { @Override public SearchInfo createFromParcel(Parcel parcel){ returnnew DataClass(parcel); }
@Override public DataClass[] newArray(int i) { returnnew DataClass[i]; } };
이걸 코틀린 코드로 변환하면 다음과 같이 만들 수 있으나-
1 2 3 4 5 6 7 8 9 10
@JvmField// 프로젝트에 자바 클래스도 존재하는 경우 JvmField 어노테이션 누락에 주의할 것 val CREATOR: Parcelable.Creator<DataClass> = object : Parcelable.Creator<DataClass> { overridefuncreateFromParcel(parcel: Parcel): DataClass { return DataClass(parcel) }
Instructs the Kotlin compiler to generate writeToParcel(), describeContents()[android.os.Parcelable] methods, as well as a CREATOR factory class automatically.
이는 자바 코드(또는 코틀린 코드) 상에서 putExtra를 통해 전달할 수 있다.
1 2
ParcelData p = new ParcelData(); intent.putExtra("extra", p);
그리고 intent를 통해 이동한 액티비티에서는 getParcelableExtra로 데이터를 받아올 수 있다.
위에 나열된 타입은 API level 26(Android 8.0 Oreo)에 deprecated 되어 아래와 같이 Android Developer 에서는 이것들 대신 TYPE_APPLICATION_OVERLAY를 쓰도록 가이드 하고 있다.
1 2 3 4 5 6 7
val param: WindowManager.LayoutParams val flag = if (Build.VERSION.SDK_INT >= Build.Version_CODES.O) { WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY } param = WindowManager.LayoutParams(width, WindowManager.LayoutParams.WRAP_CONTENT, flag, ...);
fun loadBitmapImage(context: Context, url: String?, listener: RequestListener<Bitmap?>?){ GlideApp.with(context) .asBitmap() .load(url) .listener(listener) .preload() //submit()으로 하면 Activity destroy 시점에서 오류 }
Glide를 사용해 listener를 통해 onResourceReady에서 비트맵 리소스를 받아 처리한다.
Glide를 쓰지 않고 가령, 갤러리에 저장된 이미지를 가져와 3번으로 넘어갈 경우, imageView에 비트맵을 저장하게 되는데, 이때 Bitmap의 recycle state를 관리해줘야할 수 있다. (참고: Glide에서 비트맵 이미지가 GC되는 과정) 이런 이유로 Android Developer 공식 문서에서는 대부분의 경우 Glide 라이브러리를 사용하여 앱에서 비트맵을 가져오고 디코딩하고 표시하는 것을 추천한다고 나와있다.
publicstaticvoidrequestPermission(Context context, int requestCode, String permission){ //shouldShowRequestPermissionRationale - 사용자가 권한 요청을 한번 거절 했을 경우 True if (ActivityCompat.shouldShowRequestPermissionRationale((Activity) context, permission)) { try { Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) .setData(Uri.parse("package:" + context.getPackageName())); ((Activity) context).startActivityForResult(intent, PermissionConstants.REQ_CODE_REQUEST_SETTING); } catch (ActivityNotFoundException e) {} } else { // 처음 권한 요청 및 다시 보지 않기를 선택하였을 경우 false ActivityCompat.requestPermissions((Activity) context, new String[]{permission}, requestCode); } }
3. 이미지 파일을 적절한 사이즈로 변경하여 재저장
crop을 준비하기 위해서는 디바이스의 한 화면에 적절한 크기로 이미지를 노출해야한다. 위의 코드에서 setPickedImage 메소드가 이 기능을 담당한다.