Android 커스텀 뷰: 진행률 표시줄 4가지 구현 방법

진행률 표시줄 구현 개요

안드로이드 앱에서 진행률 표시줄은 매우 중요한 UI 요소입니다. 파일 다운로드, 이미지 로딩, 웹페이지 열기 등 다양한场景에서 사용자의 앱 상태를 알려주는 필수적인 역할을 합니다. 사용자가 작업이 진행 중인지 확인하지 못하면 앱이 응답 없게 보이거나 문제가 발생했다고 느낄 수 있습니다.

이번 글에서는 안드로이드에서 진행률 표시줄을 구현하는 4가지 방법을 살펴보겠습니다.

사전 지식

학습을 시작하기 전에 다음 개념들을 이해하시면 좋습니다:

ClipDrawable 클래스: 드로어블 이미지의 일부만 표시할 수 있는 클래스입니다. level 값(0~10000)으로 표시 영역을 조절합니다.

커스텀 View 작성: View 클래스를 상속하여 자체 뷰를 구현하는 방법입니다.

구현 방법 1: 프레임 애니메이션 방식

이 방법은 진행 상황을 여러 장의 이미지로 나누어 연속적으로 표시하는 방식입니다. 이미지 로딩이나 페이지 요청처럼 구체적인 진행률 대신 "작업 중"이라는 상태만 표시하면 될 때 적합합니다.

애니메이션 리소스 파일을 생성하고 ProgressBar의 indeterminateDrawable 속성에 연결하면 됩니다.

<?xml version="1.0" encoding="utf-8"?>
<animation-list xmlns:android="http://schemas.android.com/apk/res/android"
  android:oneshot="false">
  <item android:duration="150">
    <clip
      android:clipOrientation="horizontal"
      android:drawable="@drawable/frame_01"
      android:gravity="left"/>
  </item>
  <item android:duration="150">
    <clip
      android:clipOrientation="horizontal"
      android:drawable="@drawable/frame_02"
      android:gravity="left"/>
  </item>
  <item android:duration="150">
    <clip
      android:clipOrientation="horizontal"
      android:drawable="@drawable/frame_03"
      android:gravity="left"/>
  </item>
  <item android:duration="150">
    <clip
      android:clipOrientation="horizontal"
      android:drawable="@drawable/frame_04"
      android:gravity="left"/>
  </item>
  <item android:duration="150">
    <clip
      android:clipOrientation="horizontal"
      android:drawable="@drawable/frame_05"
      android:gravity="left"/>
  </item>
  <item android:duration="150">
    <clip
      android:clipOrientation="horizontal"
      android:drawable="@drawable/frame_06"
      android:gravity="left"/>
  </item>
  <item android:duration="150">
    <clip
      android:clipOrientation="horizontal"
      android:drawable="@drawable/frame_07"
      android:gravity="left"/>
  </item>
  <item android:duration="150">
    <clip
      android:clipOrientation="horizontal"
      android:drawable="@drawable/frame_08"
      android:gravity="left"/>
  </item>
</animation-list>
<ProgressBar
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:indeterminateDrawable="@drawable/frame_animation"/>

구현 방법 2: 회전 애니메이션 방식

단일 이미지를 회전시켜 진행 중임을 표시하는 방법입니다. XML 리소스만으로 간단하게 구현할 수 있습니다.

<?xml version="1.0" encoding="utf-8"?>
<rotate xmlns:android="http://schemas.android.com/apk/res/android"
  android:pivotX="50%"
  android:pivotY="50%"
  android:fromDegrees="0"
  android:toDegrees="360"
  android:interpolator="@android:anim/accelerate_decelerate_interpolator">
  <bitmap
    android:antialias="true"
    android:filter="true"
    android:src="@drawable/spinner_icon"/>
</rotate>
<ProgressBar
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:indeterminateDrawable="@drawable/rotate_animation"/>

구현 방법 3: ClipDrawable 활용 방식

두 개의 레이어를 겹쳐 두고, 상단 이미지를 점진적으로 숨겨가는 방식입니다. ClipDrawable의 level 속성을 조절하여 진행률을 표현합니다.

먼저 XML 레이아웃을 정의합니다:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    
    <ImageView
        android:id="@+id/background_layer"
        android:layout_width="200dp"
        android:layout_height="200dp"
        android:layout_gravity="center"
        android:src="@drawable/background_circle"/>
    
    <ImageView
        android:id="@+id/progress_layer"
        android:layout_width="200dp"
        android:layout_height="200dp"
        android:layout_gravity="center"
        android:src="@drawable/clip_progress"/>
    
</FrameLayout>

ClipDrawable 리소스:

<?xml version="1.0" encoding="utf-8"?>
<clip xmlns:android="http://schemas.android.com/apk/res/android"
    android:clipOrientation="horizontal"
    android:drawable="@drawable/progress_fill"
    android:gravity="left"/>

액티비티에서 컨트롤:

public class CircularProgressView extends FrameLayout {
    private boolean isRunning;
    private int currentProgress = 0;
    private static final int MAX_LEVEL = 10000;
    
    private ClipDrawable progressClip;
    private Handler messageHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            if (msg.what == 1) {
                progressClip.setLevel(currentProgress);
            }
        }
    };
    
    public CircularProgressView(Context context) {
        this(context, null, 0);
    }

    public CircularProgressView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }
    
    public CircularProgressView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        initializeView(context);
    }
    
    private void initializeView(Context context) {
        View layoutView = LayoutInflater.from(context).inflate(R.layout.progress_layout, this, true);
        ImageView progressImage = layoutView.findViewById(R.id.progress_layer);
        progressClip = (ClipDrawable) progressImage.getDrawable();
        
        startAnimationThread();
    }
    
    private void startAnimationThread() {
        Thread animThread = new Thread(() -> {
            isRunning = true;
            while (isRunning) {
                messageHandler.sendEmptyMessage(1);
                if (currentProgress >= MAX_LEVEL) {
                    currentProgress = 0;
                }
                currentProgress += 100;
                try {
                    Thread.sleep(20);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        animThread.start();
    }

    public void stopAnimation() {
        currentProgress = 0;
        isRunning = false;
    }
}

구현 방법 4: 완전한 커스텀 뷰 (ProgressWheel)

가장 유연한 방법으로, View 클래스를 상속하여 완전히 맞춤화된 진행률 표시 위젯을 만들 수 있습니다. 원형 진행 표시줄을 구현하는 예제입니다.

public class CircleProgressIndicator extends View {

    // 뷰 크기 관련 변수
    private int viewHeight = 0;
    private int viewWidth = 0;
    private int outerRadius = 100;
    private int innerCircleRadius = 80;
    private int arcLength = 60;
    private int strokeThickness = 20;
    private int borderThickness = 20;
    private int labelSize = 20;
    private float contourThickness = 0;

    // 여백 설정
    private int marginTop = 5;
    private int marginBottom = 5;
    private int marginLeft = 5;
    private int marginRight = 5;

    // 색상 설정
    private int arcColor = 0xAA000000;
    private int contourColor = 0xAA000000;
    private int centerColor = 0x00000000;
    private int borderColor = 0xAADDDDDD;
    private int labelColor = 0xFF000000;

    // 그리기용 페인트 객체
    private Paint arcPaint = new Paint();
    private Paint centerPaint = new Paint();
    private Paint borderPaint = new Paint();
    private Paint labelPaint = new Paint();
    private Paint contourPaint = new Paint();

    // 그리기 영역
    private RectF outerRect = new RectF();
    private RectF innerCircleRect = new RectF();
    private RectF outerContourRect = new RectF();
    private RectF innerContourRect = new RectF();

    // 애니메이션 변수
    private int animationSpeed = 2;
    private int frameDelay = 0;
    private int currentProgress = 0;
    private boolean isRotating = false;

    // 텍스트 표시
    private String displayText = "";
    private String[] textLines = {};

    public CircleProgressIndicator(Context context, AttributeSet attrs) {
        super(context, attrs);
        parseCustomAttributes(context.obtainStyledAttributes(attrs, R.styleable.CircleProgressIndicator));
    }

    @Override
    protected void onMeasure(int widthSpec, int heightSpec) {
        super.onMeasure(widthSpec, heightSpec);
        
        int measuredWidth = getMeasuredWidth();
        int measuredHeight = getMeasuredHeight();
        int availableWidth = measuredWidth - getPaddingLeft() - getPaddingRight();
        int availableHeight = measuredHeight - getPaddingTop() - getPaddingBottom();
        
        int squareSize = Math.min(availableWidth, availableHeight);
        
        setMeasuredDimension(
            squareSize + getPaddingLeft() + getPaddingRight(),
            squareSize + getPaddingTop() + getPaddingBottom()
        );
    }

    @Override
    protected void onSizeChanged(int currentWidth, int currentHeight, int oldWidth, int oldHeight) {
        super.onSizeChanged(currentWidth, currentHeight, oldWidth, oldHeight);
        
        viewWidth = currentWidth;
        viewHeight = currentHeight;
        
        calculateBounds();
        configurePaints();
        invalidate();
    }

    private void calculateBounds() {
        int minDimension = Math.min(viewWidth, viewHeight);
        
        int widthOffset = viewWidth - minDimension;
        int heightOffset = viewHeight - minDimension;
        
        marginTop = getPaddingTop() + (heightOffset / 2);
        marginBottom = getPaddingBottom() + (heightOffset / 2);
        marginLeft = getPaddingLeft() + (widthOffset / 2);
        marginRight = getPaddingRight() + (widthOffset / 2);
        
        int drawableWidth = getWidth();
        int drawableHeight = getHeight();
        
        outerRect = new RectF(
            marginLeft,
            marginTop,
            drawableWidth - marginRight,
            drawableHeight - marginBottom
        );
        
        innerCircleRect = new RectF(
            marginLeft + strokeThickness,
            marginTop + strokeThickness,
            drawableWidth - marginRight - strokeThickness,
            drawableHeight - marginBottom - strokeThickness
        );
        
        float borderHalf = borderThickness / 2.0f;
        float contourHalf = contourThickness / 2.0f;
        
        innerContourRect = new RectF(
            innerCircleRect.left + borderHalf + contourHalf,
            innerCircleRect.top + borderHalf + contourHalf,
            innerCircleRect.right - borderHalf - contourHalf,
            innerCircleRect.bottom - borderHalf - contourHalf
        );
        
        outerContourRect = new RectF(
            innerCircleRect.left - borderHalf - contourHalf,
            innerCircleRect.top - borderHalf - contourHalf,
            innerCircleRect.right + borderHalf + contourHalf,
            innerCircleRect.bottom + borderHalf + contourHalf
        );
        
        outerRadius = (drawableWidth - marginRight - strokeThickness) / 2;
        innerCircleRadius = (outerRadius - strokeThickness) + 1;
    }

    private void configurePaints() {
        arcPaint.setColor(arcColor);
        arcPaint.setAntiAlias(true);
        arcPaint.setStyle(Paint.Style.STROKE);
        arcPaint.setStrokeWidth(strokeThickness);

        borderPaint.setColor(borderColor);
        borderPaint.setAntiAlias(true);
        borderPaint.setStyle(Paint.Style.STROKE);
        borderPaint.setStrokeWidth(borderThickness);

        centerPaint.setColor(centerColor);
        centerPaint.setAntiAlias(true);
        centerPaint.setStyle(Paint.Style.FILL);

        labelPaint.setColor(labelColor);
        labelPaint.setStyle(Paint.Style.FILL);
        labelPaint.setAntiAlias(true);
        labelPaint.setTextSize(labelSize);

        contourPaint.setColor(contourColor);
        contourPaint.setAntiAlias(true);
        contourPaint.setStyle(Paint.Style.STROKE);
        contourPaint.setStrokeWidth(contourThickness);
    }

    private void parseCustomAttributes(TypedArray attributes) {
        strokeThickness = (int) attributes.getDimension(
            R.styleable.CircleProgressIndicator_arcStrokeWidth, strokeThickness);

        borderThickness = (int) attributes.getDimension(
            R.styleable.CircleProgressIndicator_borderStrokeWidth, borderThickness);

        animationSpeed = (int) attributes.getDimension(
            R.styleable.CircleProgressIndicator_animationSpeed, animationSpeed);

        frameDelay = attributes.getInteger(R.styleable.CircleProgressIndicator_frameDelay, frameDelay);
        if (frameDelay < 0) {
            frameDelay = 0;
        }

        arcColor = attributes.getColor(R.styleable.CircleProgressIndicator_arcColor, arcColor);

        arcLength = (int) attributes.getDimension(
            R.styleable.CircleProgressIndicator_arcLength, arcLength);

        labelSize = (int) attributes.getDimension(
            R.styleable.CircleProgressIndicator_labelTextSize, labelSize);

        labelColor = attributes.getColor(R.styleable.CircleProgressIndicator_labelColor, labelColor);

        if (attributes.hasValue(R.styleable.CircleProgressIndicator_text)) {
            setLabelText(attributes.getString(R.styleable.CircleProgressIndicator_text));
        }

        borderColor = attributes.getColor(R.styleable.CircleProgressIndicator_borderColor, borderColor);
        centerColor = attributes.getColor(R.styleable.CircleProgressIndicator_centerColor, centerColor);
        contourColor = attributes.getColor(R.styleable.CircleProgressIndicator_contourColor, contourColor);
        contourThickness = attributes.getDimension(R.styleable.CircleProgressIndicator_contourThickness, contourThickness);

        attributes.recycle();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        
        // 내부 원 그리기
        canvas.drawArc(innerCircleRect, 0, 360, false, centerPaint);
        
        // 테두리 그리기
        canvas.drawArc(innerCircleRect, 0, 360, false, borderPaint);
        canvas.drawArc(outerContourRect, 0, 360, false, contourPaint);
        canvas.drawArc(innerContourRect, 0, 360, false, contourPaint);
        
        // 진행률 막대 그리기
        if (isRotating) {
            canvas.drawArc(innerCircleRect, currentProgress - 90, arcLength, false, arcPaint);
        } else {
            canvas.drawArc(innerCircleRect, -90, currentProgress, false, arcPaint);
        }
        
        // 텍스트 그리기
        float textHeight = labelPaint.descent() - labelPaint.ascent();
        float verticalOffset = (textHeight / 2) - labelPaint.descent();

        for (String line : textLines) {
            float horizontalOffset = labelPaint.measureText(line) / 2;
            canvas.drawText(
                line,
                getWidth() / 2 - horizontalOffset,
                getHeight() / 2 + verticalOffset,
                labelPaint
            );
        }
        
        if (isRotating) {
            scheduleNextFrame();
        }
    }

    private void scheduleNextFrame() {
        currentProgress += animationSpeed;
        if (currentProgress > 360) {
            currentProgress = 0;
        }
        postInvalidateDelayed(frameDelay);
    }

    public boolean isRotating() {
        return isRotating;
    }

    public void resetProgress() {
        currentProgress = 0;
        setLabelText("0%");
        invalidate();
    }

    public void stopRotation() {
        isRotating = false;
        currentProgress = 0;
        postInvalidate();
    }

    public void startRotation() {
        isRotating = true;
        postInvalidate();
    }

    public void incrementProgress() {
        isRotating = false;
        currentProgress++;
        if (currentProgress > 360) {
            currentProgress = 0;
        }
        setLabelText(Math.round((float) currentProgress / 360 * 100) + "%");
        postInvalidate();
    }

    public void setProgressValue(int progress) {
        isRotating = false;
        currentProgress = progress;
        postInvalidate();
    }

    public void setLabelText(String text) {
        this.displayText = text;
        this.textLines = this.displayText.split("\n");
    }

    public void setArcColor(int color) {
        this.arcColor = color;
        arcPaint.setColor(color);
        invalidate();
    }

    public void setBorderColor(int color) {
        this.borderColor = color;
        borderPaint.setColor(color);
        invalidate();
    }

    public void setLabelColor(int color) {
        this.labelColor = color;
        labelPaint.setColor(color);
        invalidate();
    }

    public void setAnimationSpeed(int speed) {
        this.animationSpeed = speed;
    }

    public void setFrameDelay(int delay) {
        this.frameDelay = delay;
    }

    public void setArcLength(int length) {
        this.arcLength = length;
        invalidate();
    }

    public void setStrokeWidth(int width) {
        this.strokeThickness = width;
        arcPaint.setStrokeWidth(width);
        calculateBounds();
        invalidate();
    }
}

마무리

안드로이드에서 진행률 표시줄을 구현하는 4가지 방법을 살펴보았습니다. 단순한 XML 설정으로 충분한 경우부터 완전히 커스텀된 뷰가 필요한 경우까지, 상황에 맞는 적절한 방법을 선택하시면 됩니다.

태그: Android custom-view progressbar uicomponent Animation

7월 30일 12:02에 게시됨