آموزش ساخت progressBar حرفه ای در اندروید
اکثر برنامه هایی که امروزه نوشته میشوند شامل پردازش های زمان بری هستند. برای مثال پردازش یک عکس یا جستجو در فایل ها یا اتصال به اینترنت و وب سرویس و … . یک برنامهی اصولی با رابط کاربری صحیح باید در اینگونه فرآیند ها کاربر را با یک progress آگاه سازد و نیز او را سرگرم کند.
امروز در صفر تا قهرمان با ما همراه باشید تا از صفر تا صد ساخت یک circular progress را بیاموزیم.
در اندروید هر چیزی که در صفحه، نمایش داده میشود یک view میباشد. یعنی یا از جنس view است یا از کلاس view ارث برده است. view ها مانند activity ها یا fragment ها و دیگر اجزای اندروید شامل یک چرخهی زندگی(lifecycle) میباشند.
اولین متدی که در هر کلاس جاوایی صدا زده میشود متد سازنده (constructor) است. در کلاس view، فایل xml با inflate به کلاس جاوایی تبدیل میشود یا به صورت مستقیم (با ساختن شی جدید) متد سازنده صدا زده میشود. سپس کلاس view به activity یا fragment متصل میشود. در مرحله بعد متد measure صدا زده میشود که وظیفه مشخص کردن ابعاد را دارد. در واقع عمل تعیین ابعاد در نهایت با صدا زدن onMeasure امکان پذیر است. از این رو در ارث بری از view فقط متد onMeasure قابلیت بازنویسی (override) را دارد. در متد layout مکان لایه و رسم ابعاد اندازه گیری شده در متد measure را برای لایه پدر و فرزندان داریم و متد onLayout نیز برای بازنویسی در ارث بری ها استفاده میکنیم. در مرحلهی بعد با متد dispatchDraw آمادهی رسم محتویات داخلی view میشویم و در متد onDraw با شی canvas میتوان دستوراتی را برای رسم به openGL ES فرستاد تا تحت GPU رسم شود. عملکرد view ها پیچیده تر از این توضیحات است اما چون هدف مقاله چیز دیگری است، از توضیح مفصل عملکرد view صرف نظر میکنیم.
۲ متد دیگر داریم که وظیفهی restart کردن view را دارند.
۱- invalidate : برای اجرای دوبارهی متد onDraw بکار میرود.
۲- reaquestLayout : برای restart کردن view (اجرای هر ۳ متد measure , layout , draw) بکار میرود.
امروز میخواهیم شکلی که در تصویر زیر میبینید را در اندروید پیاده سازی کنیم.
با ما همراه باشید.
در مرحله اول یک کلاس جاوا ZHProgress.java میسازیم(ZH همان zero to hero است) و از کلاس ImageView ارث بری میکنیم و constructor های آن را میسازیم. درون هر ۴ متد سازنده ، متد init را میسازیم.
private void init() { spinPaint = new Paint(); spinPaint.setAntiAlias(true); spinPaint.setStyle(Paint.Style.STROKE); backPaint = new Paint(spinPaint); backPaint.setColor(backColor); }
برای رسم هر چیزی در canvas ما نیاز داریم که یک شی از کلاس paint را به canvas معرفی کنیم که سبک و رنگ و دیگر خصوصیات شکل را در آن تعریف میکنیم. از آنجایی که ما یک کمان تکه تکه شده به رنگ خاکستری داریم و یک کمان که به رنگ دلخواه به روی آن به صورت چرخشی دوران میکند، پس به ۲ paint مختلف برای رسم این ۲ شکل نیاز داریم. شی spinPaint برای شکل در حال چرخش و شی backPaint برای شکل ثابت پس زمینه بکار میرود. متد setAntiAlias را با true تنظیم میکنیم تا در رسم لبه های شکل نرم تر باشند. سبک stroke را نیز برای آنها تنظیم میکنیم چون ما فقط حاشیهی دایره را نیاز داریم و به دایرهی توپر (سبک fill) نیاز نداریم.
در مرحله بعد میخواهیم لایهی ما همیشه طول و عرض یکسانی داشته (مربع) باشد، از این رو متد onMeasure را بازنویسی میکنیم و کد زیر را درون آن قرار میدهیم.
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int width = MeasureSpec.getSize(widthMeasureSpec); int height = MeasureSpec.getSize(heightMeasureSpec); int minDimension = width < height ? width : height; setMeasuredDimension(minDimension, minDimension); }
با استفاده از متد getSize از کلاس MeasureSpec ، طول و عرض لایه را میگیریم و بعد از محاسبهی کمترین بعد، آن را با متد setMeasuredDimension بر روی لایه تنظیم میکنیم.
در رسم شکل هایی که به صورت دوره ای انجام میشوند به این صورت است که متد onDraw بارها فراخوانی میشود و با استفاده از متد invalidate از شکل رسم شده از بین میرود و دوباره توسط onDraw رسم میشود و این کار با سرعت بسیار زیادی انجام میشود و چشم ما یک انیمیشین از آن را برداشت میکند. برای اینکه onDraw سرعت بیشتری داشته باشد ما سعی میکنیم که کد هایی که یکبار بیشتر نیاز به ساخته شدن ندارند را از این متد خارج کرده و در متد onSizeChanged قرار دهیم. پس متد onSizeChanged را بازنویسی میکنیم و کد زیر را درون آن مینویسیم.
@Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); cx = w / 2; cy = h / 2; radius = (int) (cx * 0.8f); circle = new RectF(cx - radius, cy - radius, cx + radius, cy + radius); int color[] = {startColor, endColor}; float angle[] = {0.6f, 1.0f}; gradient = new SweepGradient(cx, cy, color, angle); matrix = new Matrix(); int stroke = radius / 5; backPaint.setStrokeWidth(stroke); spinPaint.setStrokeWidth(stroke); segmentWith = radius / 108.0f; }
در این متد ابتدا مرکز کمانی را که میخواهیم رسم کنیم را بدست میآوریم. برای این کار طول و عرض را بر ۲ تقسیم میکنیم تا مرکز x و y را بدست آوریم. در اینجا چون طول و عرض یکسان است میتوان صرفا یکی از ابعاد را بر ۲ تقسیم کرد ولی برای ملموس تر شدن هر دو را میسازیم. حال شعاع دایره را به اندازه ۰.۸ فاصله مرکز از طرفین قرار میدهیم. برای رسم کمان در canvas ما باید یک شی از کلاس RectF را برای ابعاد به آن بدهیم. برای اینکه شکل ما دایره شود باید به این صورت شی RectF را مقدار دهی کنیم. در پارامتر اول cx-radius را قرار میدهیم، در پارامتر دوم cy-radius را قرار میدهیم، در پارامتر سوم cx+radius و در پارامتر چهارم cy+radius را قرار میدهیم. کمان رنگی ای که روی کمان خاکستری در حال چرخش است، یک دارای یک sweepGradient است که اول آن با transparent و آخر آن با رنگ دلخواه ما تنظیم شده است و یک matrix روی آن تنظیم شده که با استفاده از آن میتوان گرادینت را چرخاند. با توجه به شعاع خود پهنای paint خود را نیز تنظیم میکنیم.
حال برای کشیدن شکل مورد نظر باید متد onDraw را بازنویسی کنیم. برای رسم کمان خاکستری رنگ ما باید به اندازهی ۳۶۰ درجه تقسیم بر segmentWidth ، تعداد تکه هایی که باید ساخته شود را محاسبه میکنیم و حلقه به اندازهی تعداد تکه ها اجرا میشود ولی ما یکی در میان کمان ها را رسم میکنیم. عملکرد متد drawArc به این صورت است که شما شی RectF را به همراه زاویهی شروع و مقدار زاویهی کمان (sweepAngle) و اینکه آیا میخواهیم از مرکز به طرفین کمان متصل شود یا نه (useCenter) و در آخر شی backPaint را به آن میدهیم. همین روند را برای کمان چرخشی نیز بکار میبریم با این تفاوت که matrix را با زاویهی متفاوتی میچرخانیم و این باعث میشود که ما چرخش progress را ببینیم.
@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); int segments = (int) Math.ceil(360 / segmentWith); matrix.setRotate(startAngle, cx, cy); gradient.setLocalMatrix(matrix); spinPaint.setShader(gradient); for (int i = 0; i < segments; i++) { if (i % 2 == 0) { canvas.drawArc(circle, i * (segmentWith * 2), segmentWith, false, backPaint); canvas.drawArc(circle, i * (segmentWith * 2), segmentWith, false, spinPaint); } } }
حال باید matrix مورد نظر را بچرخانیم. برای این کار به یک thread نیاز داریم تا با توجه به سرعت ما آن را بچرخاند. کد زیر را وارد میکنیم.
public void startProgress() { stopProgress = false; new Thread(() -> { Handler mainHandler = new Handler(Looper.getMainLooper()); while (!stopProgress) { mainHandler.post(() -> { setStartAngle(startAngle); }); try { Thread.sleep(sleepTime); } catch (InterruptedException e) { e.printStackTrace(); } startAngle += 4; } }).start(); }
ما یک thread جدید ایجاد میکنیم و در آن یک حلقهی while با یک مقدار بولی قرار میدهیم که بعدا در متدی دیگر میتوانیم آن را متوقف کنیم. سپس یک فیلد با نام startAngle را با متد setStartAngle مقدار دهی میکنیم و در اینجا متد invalidate صدا زده میشود. تغییر در view ها باید در thread اصلی انجام شود، برای همین یک handler با متد سازندهای با ورودی Looper.getMainLooper میسازیم و زاویه را به وسیله این handler به thread اصلی پست میکنیم و بعد به مدت sleepTime به thread جاری وقفه میدهیم و سپس زاویه را ۴ درجه افزایش میدهیم.
سرعت چرخش به مقدار زمان وفقه یعنی sleepTime وابسته است. هرچی مقدار sleepTime بیشتر، سرعت کمتر و بالعکس. در انیمیشن و فیلم مقیاسی تحت عنوان fps (فریم بر ثانیه frame per second) داریم؛ یعنی در هر ثانیه چند تصویر در قاب تغییر میکنند. درکد زیر فرمول تبدل fps به ثانیه را داریم.
public void setSpeed(int fps) { if (fps > 200) fps = 200; this.sleepTime = 1000 / fps; }
حداکثر سرعت ۲۰۰fps است و بیش از این اجازه نمیدهیم.
کد کامل این کلاس را در اینجا میبینیم.
package ir.zerotohero.ahmadghorbi.progressbar; import android.content.Context; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Matrix; import android.graphics.Paint; import android.graphics.RectF; import android.graphics.SweepGradient; import android.os.Handler; import android.os.Looper; import android.support.annotation.Nullable; import android.util.AttributeSet; import android.widget.ImageView; /** * Created by ahmad-sw on 09/10/2017. */ public class ZHProgress extends ImageView { private Paint backPaint, spinPaint; private int radius; private int startAngle = 0; private boolean stopProgress = false; private int cx, cy; private RectF circle; private SweepGradient gradient; private Matrix matrix; private int startColor = Color.TRANSPARENT; private int endColor; private int backColor; private float segmentWith; private long sleepTime = 5; public ZHProgress(Context context) { super(context); init(); } public ZHProgress(Context context, @Nullable AttributeSet attrs) { super(context, attrs); init(); } public ZHProgress(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(); } public ZHProgress(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); init(); } private void init() { spinPaint = new Paint(); spinPaint.setAntiAlias(true); spinPaint.setStyle(Paint.Style.STROKE); backPaint = new Paint(spinPaint); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int width = MeasureSpec.getSize(widthMeasureSpec); int height = MeasureSpec.getSize(heightMeasureSpec); int minDimension = width < height ? width : height; setMeasuredDimension(minDimension, minDimension); } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); cx = w / 2; cy = h / 2; radius = (int) (cx * 0.8f); circle = new RectF(cx - radius, cy - radius, cx + radius, cy + radius); backPaint.setColor(backColor); int color[] = {startColor, endColor}; float angle[] = {0.6f, 1.0f}; gradient = new SweepGradient(cx, cy, color, angle); matrix = new Matrix(); int stroke = radius / 5; backPaint.setStrokeWidth(stroke); spinPaint.setStrokeWidth(stroke); segmentWith = radius / 108.0f; } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); int segments = (int) Math.ceil(360 / segmentWith); matrix.setRotate(startAngle, cx, cy); gradient.setLocalMatrix(matrix); spinPaint.setShader(gradient); for (int i = 0; i < segments; i++) { if (i % 2 == 0) { canvas.drawArc(circle, i * (segmentWith * 2), segmentWith, false, backPaint); canvas.drawArc(circle, i * (segmentWith * 2), segmentWith, false, spinPaint); } } } public int getStartAngle() { return startAngle; } public void setStartAngle(int startAngle) { this.startAngle = startAngle; invalidate(); } public int getRadius() { return radius; } public void setRadius(int radius) { this.radius = radius; invalidate(); } public void startProgress() { stopProgress = false; new Thread(() -> { Handler mainHandler = new Handler(Looper.getMainLooper()); while (!stopProgress) { mainHandler.post(() -> { setStartAngle(startAngle); }); try { Thread.sleep(sleepTime); } catch (InterruptedException e) { e.printStackTrace(); } startAngle += 4; } }).start(); } public void stopProgress() { stopProgress = true; } public void setCycleColor(int color) { this.startColor = Color.TRANSPARENT; this.endColor = color; if (gradient != null) { int[] colors = {startColor, color}; float angle[] = {0.5f, 1.0f}; gradient = new SweepGradient(cx, cy, colors, angle); Matrix m = new Matrix(); m.setRotate(startAngle, cx, cy); gradient.setLocalMatrix(m); invalidate(); } } public void setBackColor(int color) { this.backColor = color; requestLayout(); } public void setSpeed(int fps) { if (fps > 200) fps = 200; this.sleepTime = 1000 / fps; } }
اکنون به صورت زیر میتوان از آن استفاده کرد.
<?xml version="1.0" encoding="utf-8"?> <RelativeLayout 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:layout_width="match_parent" android:layout_height="match_parent" tools:context="ir.zerotohero.ahmadghorbi.progressbar.MainActivity"> <ir.zerotohero.ahmadghorbi.progressbar.ZHProgress android:id="@+id/zhProgress" android:layout_centerHorizontal="true" android:layout_width="200dp" android:layout_height="200dp" /> </RelativeLayout>
package ir.zerotohero.ahmadghorbi.progressbar; import android.graphics.Color; import android.support.v7.app.AppCompatActivity; import android.os.Bundle; public class MainActivity extends AppCompatActivity { private ZHProgress zhProgress; private boolean isTrue=true; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); zhProgress = (ZHProgress) findViewById(R.id.zhProgress); zhProgress.setOnClickListener((v) -> { if (isTrue) { zhProgress.stopProgress(); isTrue=false; }else { zhProgress.startProgress(); isTrue=true; } }); zhProgress.setCycleColor(Color.parseColor("#7B1FA2") ); zhProgress.setBackColor(Color.parseColor("#BDBDBD")); } }
امیدوارم از این آموزش لذت برده باشید و بتوانید progress های زیبایی را برای خودتان بسازید.
مطالب زیر را حتما مطالعه کنید
آموزش Gradle – اهمیت Project Automation
درک مفهوم کدنویسی تمیز در اندروید
5 هک ساده برای کاهش سایز فایل APK
آشنایی با RecyclerView در اندروید
Open/Closed Principle در قوانین Solid
توابع در زبان برنامه نویسی Kotlin
2 Comments
Join the discussion and tell us your opinion.
دیدگاهتان را بنویسید لغو پاسخ
برای نوشتن دیدگاه باید وارد بشوید.
درود
خیلی عالی بود . سپاسگذارم.
اگه لطف کنید و بصورت حرفه ای تر هم آموزش بذارید خیلی خوب میشه مثلا بشه وسط همین پروگرس بار یک عکس دایره ای هم گذاشت.
بازم ممنون.
ممنون از نظرتون.
درآموزش های بعدی حتما .