[Android] CustomView

gang_shik·2021년 9월 2일
0

CustomView?

  • 기존의 View를 가지고 다양한 설계와 디자인 시안을 통해서 활용을 할 수 있음

  • 하지만 이런 기존 View뿐만 아니라 내가 생각하고 설계하고픈 View가 원하는 그림이 나오지 않을 수가 있고 내가 원하는대로 커스텀을 하고 싶을 수가 있음

  • 이때 CustomView를 활용하여 내가 원하는 뷰를 따로 만들고 구현을 할 수 있음

  • 별도의 클래스 파일을 만들어서 해당 요소에 대해서 파라미터로 기본값을 받고 직접 XML 요소로 추가할 수 있음

  • 그 외에 직접 아래와 같이 단순한 버튼 아이콘을 변경하는 것이 아닌 View를 그려야 하는 경우도 있음

  • 이 때 아래의 예시와 같이 배경을 바꾸는 수준이 아닌 직접 View를 그릴 수도 있음

  • 이런 경우 onDraw() 메소드를 재정의 할 수 있음, 이러면 Canvas 객체를 직접 불러와서 텍스트, 선, 비트맵 등 기타 여러가지 그래픽에 대해서 그릴 수 있음, 이를 통해 맞춤 사용자 인터페이스(UI)를 만들 수 있음

  • 이 메서드 호출을 위해서 Paint 객체를 만들고 활용 가능함


예시

CustomView를 통해 아이콘 변경

  • 녹음기 버튼을 만드려고 할 때 녹음하는 상태에 따라서 버튼의 아이콘을 변경하기 위한 CustomView를 만듬

  • 먼저 클래스로 만들어서 조건에 따라 돌아가도록 구현을 함

package techtown.org.recorder

import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatImageButton

/*
CustomView로써 xml 파일에서 수정을 하게 하기 위한 클래스
그러면 이제 이 클래스에서 상태에 따라 UI를 수정하게끔 할 수 있음, 아래와 같이 ImageButton을 상속받고
미리 정의한 State 상태에 따라서 버튼을 바꾸면 됨
 */
class RecordButton( // CustomView로 활용하기 위해서 아래와 같이 파라미터를 기본적으로 받아줘야함
        context: Context,
        attrs: AttributeSet
): AppCompatImageButton(context,attrs) {
    // State에 따라서 recordButton의 아이콘을 변경함
    // 앞서 정의한 State 클래스를 매개변수로 받아와서 when 분기문을 통해서 상태에 따라 Vector Asset으로 추가한 아이콘을 변경함
    fun updateIconWithState(state: State) {
        when(state) {
            State.BEFORE_RECORDING -> {
                setImageResource(R.drawable.ic_record)
            }
            State.ON_RECORDING -> {
                setImageResource(R.drawable.ic_stop)
            }
            State.AFTER_RECORDING -> {
                setImageResource(R.drawable.ic_play)
            }
            State.ON_PLAYING -> {
                setImageResource(R.drawable.ic_stop)
            }
        }
    }
}
  • 이렇게 만든 CustomView에 대해서 xml 상에서도 추가해서 만들 수 있음
<!--앞서 클래스로 정의한 RecordButton을 가져와서 사용할 수 있음-->
    <techtown.org.recorder.RecordButton
        android:id="@+id/recordButton"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:layout_marginBottom="50dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent" />
  • 그리고 위에서 CustomView를 만든 클래스와는 별도로 이제 이 RecordButton이라는 CustomView를 상태에 따라 아이콘을 변경하기 위해서 메인에서 state에 따라서 변경할 수 있게 쓰고 또 버튼의 기능을 가지고 있기 때문에 이 버튼의 기능을 쓰기 위한 리스너처리도 가능함
// 초기 상태를 위해서 선언한 변수 상태는 언제든지 변할 수 있으니 var로 지정
    private var state = State.BEFORE_RECORDING
        set(value) {
            // 상태에 따라 UI를 변경해주기 위한 Setter, 새로운 state 들어올 때마다 Icon이 업데이트 됨
            field = value
            resetButton.isEnabled = (value == State.AFTER_RECORDING) ||
                    (value == State.ON_RECORDING) // reset 버튼 State 설정 녹음완료 재생되는 시점에 resetButton이 활성화됨
            recordButton.updateIconWithState(value)
        }
        
        .....
        
        
        recordButton.setOnClickListener {
            // 상태마다 다르게 행동함
            when(state) {
                State.BEFORE_RECORDING -> {
                    startRecording()
                }
                State.ON_RECORDING -> {
                    stopRecording()
                }
                State.AFTER_RECORDING -> {
                    startPlaying()
                }
                State.ON_PLAYING -> {
                    stopPlaying()
                }
            }
        }
        
  • 이처럼 기존의 View에서 내가 원하는 방향대로 Custom을 하여서 활용하기 위해서 CustomView를 쓸 수 있음

CustomView를 만들어서 직접 View를 그리는 예시

  • 녹음기 중에서 녹음하는 과정에서 나의 목소리를 보고 파장 형태로 나오는 경우가 있음

  • 이 때 이런 소리를 시각화하는 View를 직접 CustomView로 활용하여 사용함

/*
녹음이 잘 되고 있는지 녹음하는 부분에 대해서 시각화를 하기 위한 CustomView
 */
class SoundVisualizerView(
        context: Context,
        attrs: AttributeSet? = null
) : View(context, attrs) {

    // 이를 통해 현재 Amplitude에 대해서 메인에서 받을 수 있음
    var onRequestCurrentAmplitude: (() -> Int)? = null

    // 음향 진폭을 시각화하고 Paint로 그릴 것임
    private val amplitudePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply{
        // 어떤 색으로 그릴지 지정
        color = context.getColor(R.color.purple_500)
        // 길이 지정
        strokeWidth = LINE_WIDTH
        // 라인의 양 끝을 Round 처리함
        strokeCap = Paint.Cap.ROUND
    } // 곡선이 좀 더 부드럽게 그려짐 ANTI_ALIAS
    // 그려야 할 사이즈 정해야함, 리소스를 많이 차지하므로 크기에 대해서는 미리 정하고 제대로 처리해야함
    private var drawingWidth: Int = 0
    private var drawingHeight: Int = 0
    // 리스트로 진폭을 저장하고 드로잉에 쓰기 위한 리스트
    private var drawingAmplitudes: List<Int> = emptyList()

    // 다시 플레이를 할 때도 시각화를 위해 선언, Replaying인지 확인하기 위해서 true/false로 구분
    private var isReplaying: Boolean = false
    private var replayingPosition: Int = 0



    // 시간이 지나가면서 흐르듯이 움직이는 듯한 효과를 줘야함, 실행시 그릴때 20프레임으로 움직이면서 스무스하게 녹음이 되듯이 오른쪽으로 녹음창이 흐름
    private val visualizeRepeatAction: Runnable = object : Runnable {
        override fun run() {
            if(!isReplaying) {
                val currentAmplitude = onRequestCurrentAmplitude?.invoke()
                        ?: 0 // 메인에 bindViews에 있는걸 가져옴, 그럼 오디오에 maxAmplitude가 들어옴(메인에서 설정했으므로)
                // Amplitude를 가져오고 Draw를 요청함
                drawingAmplitudes = listOf(currentAmplitude) + drawingAmplitudes // 순차적으로 넣기 위해서 설정함
            } else {
                // replaying시 멈춘 순간에서 다시 그 포지션 기준으로 추가하는 것이므로 Position을 더함
                replayingPosition++
            }
            invalidate() // onDraw를 다시 호출하기 위해, 갱신을 위해서
            handler?.postDelayed(this, ACTION_INTERVAL)
        }
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        // 사이즈가 깨지면 안되고 제대로 처리해야하므로 들어온 값에 대해서 설정을 해줌, 이러면 깨질일이 없음
        drawingWidth = w
        drawingHeight = h
    }

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        // 진폭을 그리기 위해서 onDraw를 오버라이딩함, 진폭 표시는 캔버스에 선을 긋고 길이와 선 사이의 간격을 그리게끔 요청할 것임(무엇을 그릴건지)
        // 그다음 Paint에 어떤 굵기, 색깔이 있는지 처리해줘야함
        // Height는 실제 값에 기반해야함, 클 때는 길게 작을 때는 짧게 나오므로
        // Amplitude, 볼륨에 맞춰서 그 길이를 지정함

        canvas ?: return

        // 선에 중앙값을 그림(길이의 절반)
        val centerY = drawingHeight / 2f
        // 수많은 진폭값을 리스트로 저장하고 하나씩 오른쪽으로 채워나가면서 그릴 것임
        // 시작 포인트는 그리는 영역에 오른쪽 가로길이
        var offsetX = drawingWidth.toFloat()

        // 리스트에 담긴 것을 하나씩 그림
        drawingAmplitudes
                .let{
                    // 만약 Replaying 하는 상태라면 위에서 저장한 것 기준으로 그 다음 추가해서 그림
                    // 아닌 경우는 그냥 그리면 됨
                    amplitudes ->
                    if(isReplaying){
                        amplitudes.takeLast(replayingPosition)
                    } else {
                        amplitudes
                    }
                }
                .forEach { amplitude ->
            // 실제 그릴 length, 현재 진폭값에서 최대값을 나누고 그릴려는 높이를 전달하면 높이 대비 몇 % 그릴지 알 수 있음
            val lineLength = amplitude / MAX_AMPLITUDE * drawingHeight * 0.8F // 100일 경우 꽉차는 것을 대비하기 위해 0.8을 곱함

            // LINE_SPACE만큼 감소시키며 계속 우측으로 차례대로 그리므로
            offsetX -= LINE_SPACE

            // 다 못 그리는 왼쪽을 넘어가는 영역이 있을 수 있음, 뷰를 넘어서는 부분
            if(offsetX < 0) return@forEach

            // 시작과 끝을 전달받고 점을 찍고 종료하는 시점과 어떻게 그릴지 설정
            canvas.drawLine(
                    offsetX,
                    centerY - lineLength / 2F,
                    offsetX,
                    centerY + lineLength / 2F,
                    amplitudePaint
            )
        }

    }

    // 반복해서 호출하면서 그리게 됨
    fun startVisualizing(isReplaying: Boolean) {
        this.isReplaying = isReplaying
        handler?.post(visualizeRepeatAction)
    }

    // 반복 호출을 멈추는 메소드
    fun stopVisualizing() {
        // 여러번 다시 재생할 때 반복을 위해서
        replayingPosition = 0
        handler?.removeCallbacks(visualizeRepeatAction)
    }

    // 기존의 Drawing을 초기화하는 메서드
    fun clearVisualization() {
        drawingAmplitudes = emptyList()
        invalidate()
    }

    companion object {
        // 그리려고 하는 값은 미리 정하고 알아야 하기 때문에 상수로 저장
        private const val LINE_WIDTH = 10F
        private const val LINE_SPACE = 15F
        // 최대 볼륨에 대해서 지정함
        private const val MAX_AMPLITUDE = Short.MAX_VALUE.toFloat() // 0이 되는 현상을 방지하기 위해서 Float으로 바꿈
        private const val ACTION_INTERVAL = 20L
    }
}
  • 여기서 onDraw를 통해서 직접 요소를 그리는 것인데 상수로 정의하는 것은 만일 미리 정의하고 그린다면 원하는 결과가 안 나올 수도 있음, 그리고 CustomView이기 때문에 이 부분에 한해서는 어느정도 정하고 그 범주내로 그리기때문에 상수값으로 정의함

  • 그리고 그 상수범위 기준으로 실제 오디오의 파장을 가지고 와서 길이와 공백 굵기 등등 선을 그리는데 있어서 요소들을 다 정한 뒤 onDraw를 활용해서 직접 그림

  • 이 때 Canvas 객체를 활용하고 실제 그리려는 범위를 다 지정해줘야 함

  • 여기서 짚고 넘어갈 부분은 우리가 흔히 쓰는 View들을 활용할 수도 있지만 위에서도 언급했다시피 커스텀을 하게 될 경우 한해서는 직접 클래스를 만들어서 사용할 View 요소들을 상속받고 활용할 수 있음


참고자료

profile
Stay Humble, Hustle Hard

0개의 댓글