진행률 표시줄 구현 개요
안드로이드 앱에서 진행률 표시줄은 매우 중요한 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 설정으로 충분한 경우부터 완전히 커스텀된 뷰가 필요한 경우까지, 상황에 맞는 적절한 방법을 선택하시면 됩니다.