[Android] Custom Gallery 구현 : 슬라이드 커스텀 갤러리 구현하기

 

[요구사항]

 

- 사용자 프로필 변경 시 이미지 선택

- 갤러리에서 이미지를 사용하는 것이 아닌 커스텀 갤러리 구현

- 이미지를 불러와서 슬라이드 뷰에 적용 

 

1. 갤러리 사용 권한 받기 

- 권한 설정에 TedPerMission을 사용한다.

- 간편하게 사용자 권한 코드 작성 가능 

implementation 'io.github.ParkSangGwon:tedpermission:2.3.0'

- ManiFest에 추가 

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

 

2. 슬라이딩 패널 라이브러리 추가

//slide bar
implementation 'com.sothree.slidinguppanel:library:3.4.0'

 

 

- 슬라이딩 패널의 주요 요소

android:gravity="bottom"
app:umanoDragView="@id/slide_layout_in_update_profile"
app:umanoOverlay="true"
app:umanoPanelHeight="0dp"

- gravity : 필수 - 패널 나타나는 위치

- umanoDragView : 숨겨질 뷰 (서브 뷰 )

- umanoOverlay : 배경 레이아웃이 공간을 차지하는지 설정 (false : 슬라이드 뷰가 기존 뷰를 밀고 올라옴)

- umanoPanelHeight : 숨겨져 있을 때의 높이

 

- 구체적인 레이아웃 (메인 뷰)

 

제일 하단의 frame Layout이 슬라이딩 뷰를 담당

<com.sothree.slidinguppanel.SlidingUpPanelLayout 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/slide_frame_in_update_profile"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="bottom"
    app:umanoDragView="@id/slide_layout_in_update_profile"
    app:umanoOverlay="true"
    app:umanoPanelHeight="0dp"
    tools:context=".UIFragment.ProfileUpdateFragment">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:gravity="center_horizontal"
        android:orientation="vertical"
        android:padding="@dimen/fab_margin">

        <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="@dimen/item_mid_50_height"
            android:layout_marginBottom="@dimen/fab_margin"
            android:orientation="horizontal">

            <ImageView
                android:id="@+id/back_button"
                android:layout_width="@dimen/item_mid_40_height"
                android:layout_height="@dimen/item_mid_40_height"
                android:background="@drawable/circle_border"
                android:src="@drawable/ic_baseline_arrow_back_24"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent" />

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="@string/update_profile"
                android:textColor="@color/color_type1"
                android:textSize="@dimen/item_height_big_text_size_35"
                android:textStyle="bold"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent" />
        </androidx.constraintlayout.widget.ConstraintLayout>

        <de.hdodenhof.circleimageview.CircleImageView
            android:id="@+id/user_image"
            android:layout_width="@dimen/item_height_big_150"
            android:layout_height="@dimen/item_height_big_150"
            android:layout_margin="@dimen/main_margin_10"
            android:background="@drawable/circle"
            android:padding="@dimen/main_margin" />

        <TextView
            android:layout_width="match_parent"
            android:layout_height="@dimen/item_height"
            android:layout_margin="@dimen/fab_margin_12dp"
            android:gravity="center_vertical"
            android:orientation="horizontal"
            android:padding="@dimen/main_margin"
            android:text="@string/enter_your_new_id"
            android:textColor="@color/strong_color"
            android:textSize="@dimen/item_height_big_text_size"
            android:textStyle="bold" />

        <EditText
            android:id="@+id/insert_id"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_margin="@dimen/fab_margin_12dp"
            android:gravity="center"
            android:textSize="@dimen/item_height_big_21_text_size"
            android:textStyle="bold" />

        <TextView
            android:layout_width="match_parent"
            android:layout_height="@dimen/item_height"
            android:layout_margin="@dimen/fab_margin_12dp"
            android:gravity="center_vertical"
            android:orientation="horizontal"
            android:padding="@dimen/main_margin"
            android:text="@string/enter_your_new_comment"
            android:textColor="@color/strong_color"
            android:textSize="@dimen/item_height_big_text_size"
            android:textStyle="bold" />

        <EditText
            android:id="@+id/insert_comment"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_margin="@dimen/fab_margin_12dp"
            android:gravity="center"
            android:textSize="@dimen/item_height_big_21_text_size"
            android:textStyle="bold" />

        <androidx.appcompat.widget.AppCompatButton
            android:id="@+id/commit_button"
            style="@style/ButtonText_big"
            android:layout_width="@dimen/item_we_300"
            android:layout_height="@dimen/item_height_big_70"
            android:layout_gravity="center"
            android:layout_margin="@dimen/main_margin_10"
            android:text="commit" />
    </LinearLayout>

    <FrameLayout
        android:id="@+id/slide_layout_in_update_profile"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</com.sothree.slidinguppanel.SlidingUpPanelLayout>

 

3. 슬라이딩 레이아웃을 Fragment로 구현 

 

class SelectPicFragment(private val mainFrame : SlidingUpPanelLayout) : Fragment()

 

프로필을 변경하는 메인 뷰에서 위에서 선언한 프레임 레이아웃을 프래그먼트로 변경 

selectPicFragment=  SelectPicFragment(binding.slideFrameInUpdateProfile)
//슬라이드 레이아웃 view 설정
selectPicFragment?.let {fragment->
    mainActivity.frManger.beginTransaction()
        .replace(R.id.slide_layout_in_update_profile,fragment)
        .commit()
}

 

 

4. 슬라이딩 레이아웃 뷰 작성

 

이미지를 보여줄 리싸이클러 뷰와 어댑터 필요 

 

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:clickable="true"
    android:background="@color/white"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".UIFragment.SelectPicFragment"
    android:focusable="true">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="@dimen/item_height_big_80">
        <TextView
            android:id="@+id/cancel_button"
            android:textSize="@dimen/item_height_big_21_text_size"
            android:text="@string/back"
            android:textStyle="bold"
            android:gravity="center"
            android:layout_marginStart="@dimen/item_mid_height_20dp"
            app:layout_constraintStart_toStartOf="parent"
            android:layout_width="wrap_content"
            android:layout_height="match_parent"/>
        <TextView
            android:id="@+id/upload_button"
            android:textSize="@dimen/item_height_big_21_text_size"
            android:text="@string/commit"
            android:textStyle="bold"
            android:gravity="center"
            android:layout_marginEnd="@dimen/item_mid_height_20dp"
            app:layout_constraintEnd_toEndOf="parent"
            android:layout_width="wrap_content"
            android:layout_height="match_parent"/>
    </androidx.constraintlayout.widget.ConstraintLayout>
    <FrameLayout
        android:id="@+id/line"
        android:background="@color/color_type1"
        android:layout_width="match_parent"
        android:layout_height="1dp"/>
    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="@dimen/item_we_300">
        <ImageView
            android:layout_gravity="center"
            android:id="@+id/select_image"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"/>
    </FrameLayout>
    <FrameLayout
        android:background="@color/color_type1"
        android:layout_width="match_parent"
        android:layout_height="1dp"/>
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/image_recycler"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
        app:spanCount="3"
        tools:listitem="@layout/item_one_pic"
        android:orientation="vertical"
        />
</LinearLayout>

SelectPicAdapter로 이미지를 나타내고 클릭 이벤트를 적용

 

- 이미지 Uri를 리스트로 받아서 데이터를 보여줌 

- 클릭 이벤트 발생 시 선택 아이콘을 나타내고 uri를 어댑터를 선언한 곳으로 전달 

- 0번째 인덱스의 사진을 클릭할 경우, 사진을 촬영하라는 신호인 camera! : String 전달 

class SelectPicAdapter(
    private val mainActivity: MainActivity,
    private var itemList: ArrayList<Uri>,
) : RecyclerView.Adapter<SelectPicAdapter.SelectPicViewHolder>() {

    private lateinit var binding: ItemOnePicBinding
    private var onItemClickListener :OnItemClickListener?=null
    private var selectedPicNum = -1
    interface OnItemClickListener{
        fun onItemClick(imageUri:Uri){
        }
    }
    fun setOnItemClickListener(listener: OnItemClickListener){
        this.onItemClickListener = listener
    }

    inner class SelectPicViewHolder(private val binding: ItemOnePicBinding) :
        RecyclerView.ViewHolder(binding.root) {
        @SuppressLint("NotifyDataSetChanged")
        fun bind(){
            val item = itemList[absoluteAdapterPosition]

            //카메라 뷰  = 0
            if (absoluteAdapterPosition==0){
                binding.checkImage.setBackgroundResource(R.drawable.baseline_camera_alt_24)
                binding.checkBox.setImageResource(0) //카메라는 체크 박스 제거
            }else {
                Glide.with(mainActivity)
                    .load(item)
                    .into(binding.checkImage)
                if (selectedPicNum == absoluteAdapterPosition) {
                    binding.setCheck()
                } else {
                    binding.setUnCheck()
                }
            }
            binding.root.setOnClickListener {

                //카메라 세팅
                if (absoluteAdapterPosition ==0){
                    onItemClickListener?.onItemClick("camera!".toUri())

                //재 클릭 시 선택 종료
                } else if (absoluteAdapterPosition == selectedPicNum) {
                    onItemClickListener?.onItemClick("".toUri()) //선택 uri 전달
                    selectedPicNum = -1

                //이미지 클릭 시 uri 전달
                } else {
                    onItemClickListener?.onItemClick(item) //선택 uri 전달
                    selectedPicNum = absoluteAdapterPosition
                }

                notifyDataSetChanged()
            }
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SelectPicViewHolder {
        binding = ItemOnePicBinding.inflate(LayoutInflater.from(mainActivity),parent,false)
        return SelectPicViewHolder(binding)
    }

    override fun getItemCount() = itemList.size

    override fun onBindViewHolder(holder: SelectPicViewHolder, position: Int) {
        holder.bind()
    }

    private fun ItemOnePicBinding.setCheck() =
        checkBox.setBackgroundResource(R.drawable.baseline_check_circle_24)

    private fun ItemOnePicBinding.setUnCheck() =
        checkBox.setBackgroundResource(R.drawable.circle)
}

 

5. 슬라이딩 레이아웃 프래그먼트 작성

 

- 이미지 리스트를 보여주고 , 사용자 권한을 요청하는 코드 작성 

- 이미지 선택 시 main view로 Uri 전송 

 

- 권한 확인 코드

 

SelectPicAdapter :  위에서 작성한 갤러리에서 불러온 사진들을 보여주기 위한 어댑터 

- getGallery() 함수를 통해서 이미지 리스트를 가져옴 (아래에 참조)

- 권한이 확인되면 어댑터를 생성하고 이미지 리스트를 보여준다.

- 위의 어댑터에서 생성한 클릭 이벤트를 통해서 Uri를 전달받고 이미지를 나타냄

- 만약에 사진 촬영을 하라는 "camera!" 인자가 올 경우 사진 촬영 함수인 openCamera() 실행 (아래에 참조)

- 권한 요청이 실패할 경우 메시지 전달 

private lateinit var permissionListener : PermissionListener
//권한 확인
permissionListener = object : PermissionListener{
    override fun onPermissionGranted() {
        val adapter = SelectPicAdapter(mainActivity,getGallery())
        binding.imageRecycler.adapter = adapter
            .apply {
                setOnItemClickListener(object : SelectPicAdapter.OnItemClickListener{
                    override fun onItemClick(imageUri: Uri) {
                        if (imageUri.toString()==""){
                            binding.selectImage.setImageResource(0)
                            lastUri = imageUri
                        }else if (imageUri.toString()=="camera!"){
                            //카메라 실행 코드
                            openCamera()
                        }else{
                            Glide.with(mainActivity)
                                .load(imageUri)
                                .into(binding.selectImage)
                            lastUri = imageUri
                        }
                    }
                })
            }
    }

    override fun onPermissionDenied(deniedPermissions: MutableList<String>?) {
        Toast.makeText(mainActivity,"권한 필요", Toast.LENGTH_SHORT)
            .show()
    }

}

 

제작한 권한을 적용하는 코드 (반드시 onCreate() or onCreated() 내부에서 실행되어야 함)

 

- 카메라 촬영 권한, 사진을 읽고 쓰는 권한 적용 

- 권한 허용을 위해서 사용자에게 아래와 같은 메시지 나타냄 

//권한 적용
TedPermission.with(mainActivity)
    .setPermissionListener(permissionListener)
    .setPermissions(
        Manifest.permission.CAMERA,
        Manifest.permission.READ_EXTERNAL_STORAGE,
        Manifest.permission.WRITE_EXTERNAL_STORAGE
    ).check()

 

6. getGallery()  : 사용자 이미지 리스트를 불러오는 함수 

 

- 0번째에는  카메라 촬영 옵션을 넣기 위해서 빈 값을 넣어줌 

- 커서를 이동하면서 이미지리스트 작성 

private fun getGallery() :ArrayList<Uri>{
    val picList = ArrayList<Uri>()
    picList.add(Uri.parse("")) //첫번째는 카메라
    val uriExternal : Uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
    var columnIndexId : Int
    val cursor = mainActivity.contentResolver.query(
        uriExternal,null,null,null,
        MediaStore.Images.ImageColumns.DATE_TAKEN+" DESC"
    )
    if (cursor!=null){
        while (cursor.moveToNext()){
            columnIndexId = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
            val imageId = cursor.getLong(columnIndexId)
            val uriImage = Uri.withAppendedPath(uriExternal,""+imageId)
            picList.add(uriImage)
        }
        cursor.close()
    }
    return picList
}

 

 

7. openCamera() : 사용자 카메라 촬영을 진행하는 함수 

 

- 사진 촬영을 진행하고, Uri를 전달받아서 선택한 이미지로 적용 

private val startForResult = registerForActivityResult(ActivityResultContracts
    .StartActivityForResult()) {}
private fun openCamera(){
    val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
    createImageUri(newFileName())?.let { uri->
        createUri = uri
        intent.putExtra(MediaStore.EXTRA_OUTPUT,createUri)
        startForResult.launch(intent)
        createUri?.let {
            Glide.with(mainActivity)
                .load(createUri)
                .into(binding.selectImage)
            lastUri =createUri
        }
    }

}

 

 

새로운 파일이 생성되었으므로 새로운 이미지 파일을 생성하는 함수도 작성 

 

//새 파일 이름 생성
@SuppressLint("SimpleDateFormat")
private fun newFileName() : String {
    val sdf = SimpleDateFormat("yyyyMMdd_HHmmSS")
    return "${sdf.format(System.currentTimeMillis())}.jpeg"
}
//이미지 파일 생성 함수
private fun createImageUri(filename : String) : Uri?{
    val values = ContentValues()
    values.put(MediaStore.Images.Media.DISPLAY_NAME,filename)
    values.put(MediaStore.Images.Media.MIME_TYPE,"image/jpeg")

    return mainActivity.contentResolver
        .insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,values)
}

 

7. 클릭 이벤트 작성

 

- 서브 뷰 (슬라이딩 뷰)를 불러온 메인 뷰로 선택한 Uri를 전달하기 위해서 클릭 이벤트를 적용

private var onItemClickListener : OnItemClickListener?= null

interface OnItemClickListener{
    fun onItemClick(lastUri:Uri?){}
}
fun setOnItemClickListener(listener: OnItemClickListener){
    this.onItemClickListener = listener
}

 

업로드 버튼 클릭 시 슬라이딩 뷰를 종료하고,

클릭 이벤트를 발생시켜서 메인 뷰로 데이터 전달 

binding.uploadButton.setOnClickListener {
    mainFrame.panelState = SlidingUpPanelLayout.PanelState.COLLAPSED
    onItemClickListener?.onItemClick(lastUri)
}

 

메인 뷰에서 클릭 이벤트 처리  : 선택 Uri를 받아서 메인 뷰의 이미지에 적용 

// 슬라이드로 이미지 변경 프래그먼트 처리
// 클릭 이벤트 시 이미지 가져옴
selectPicFragment=  SelectPicFragment(binding.slideFrameInUpdateProfile)
    .apply {
    setOnItemClickListener(object :SelectPicFragment.OnItemClickListener{
        override fun onItemClick(lastUri: Uri?) {
            imageUri =
                if (lastUri.toString()!=""){
                lastUri
            }else{
                Uri.parse(mainActivity.viewModel.myProfile.userImage)
            }
            Glide.with(mainActivity)
                .load(imageUri)
                .into(binding.userImage)
        }
    })
}

 

실행 화면

 

 

대표 이미지용