ایجاد نمای سفارشی (Custom View) در اندروید — از صفر تا صد
پلتفرم اندروید بازه وسیعی از ویجتهای رابط کاربری را ارائه کرده است که برای نیازهای اغلب اپلیکیشنها کافی هستند. این ویجتها عالی هستند و محصولات نهایی کاملاً کاربردی و زیبایی را میسازند؛ اما در پارهای موارد توسعهدهندگان نرمافزار تمایل دارند فراتر از این بیندیشند و رابطهای سفارشی خاص خود را بسازند. بهترین روش برای پاسخ دادن به این خلاقیت، ساخت نمای سفارشی است.
در این نوشته به تعریف و برسی نماهای سفارشی و سپس بخش جذابتر یعنی نمایش آنها خواهیم پرداخت.
اصطلاحات مهم
در آغاز باید برخی اصطلاحهای مقدماتی را برای درک بهتر تعریف کنیم.
نمای اندروید (Android View)
نمای اندروید یا Android View یک کلاس مبنا برای ساخت رابط کاربری است که به توسعهدهندگان فرصتی برای ایجاد طراحیهای پیچیده میدهد. این نما ناحیهای مستطیلی روی صفحه اشغال میکند و مسئول اندازهگیری، طرحبندی و ترسیم خود در راستای عناصر فرزندش است. به علاوه نماها همه ورودیهای کاربر را مدیریت میکنند.
گروه نما (ViewGroup)
یک گروه نما یا ViewGroup، نمای خاصی است که توانایی گنجاندن نماهای دیگر (فرزندان) را در خود دارد و مشخصات طرحبندی خاص خود را تعریف میکند. این نما همچنین محلی است که هر نمای فرعی (subview) میتواند از آن مشتق شود.
نمای سفارشی (Custom View)
هر نمایی که خارج از ویجت مبنای اندروید ایجاد شود را میتوان یک نمای سفارشی یا Custom View دانست. در این نوشته کل توجه ما روی این نمای سفارشی است.
روشهای پیادهسازی
روشهای بسیار مختلفی برای پیادهسازی نماهای سفارشی وجود دارند و رویکردی که انتخاب میشود به نیازهای شما بستگی دارد. در ادامه برخی روشها را مورد بررسی قرار میدهیم:
بسط دادن ویجت اندروید موجود
این روش زمانی که حجم بالایی از کد راهاندازی برای نمای شما لازم باشد و بخواهید از آن در جاهای مختلفی استفاده کنید مناسب خواهد بود. برای اجتناب از درج کد شلوغ درون اکتیویتی یا فرگمان، میتوانید ویجت مبنا را بسط داده و همه کارهای راهاندازی را درون سازنده انجام دهید، از این رو میتوان به سادگی از آن مجدداً استفاده کرد. این روش به طور بدیهی سادهترین رویکرد برای پیادهسازی نماهای سفارشی محسوب میشود.
بسط دادن نمای مبنای اندروید
اگر میخواهید ابتکار به خرج بدهید و همه کارها را از صفر خودتان اجرا کنید، این روش مناسب خواهد بود. در این روش شما همه منطق رفتاری ویجت را خودتان رسم، اندازهگیری و برنامهریزی میکنید.
گروهبندی نماهای موجود با هم
در پارهای اوقات مجموعهای از ویجتها دارید که میخواهید آنها را با هم گروهبندی کنید تا یک نمای کاملاً جدید ایجاد کنید. برای نمونه فرض کنید یک Textview و یک Button دارید و میخواهید آنها را درون یک LinearLayout گروهبندی کنید. این نما عموماً به نام «نمای ترکیبی» (Compound View) شناخته میشود. مزیتهای این کار به شرح زیر هستند:
- یک منطق کپسولهسازی شده و متمرکز داریم.
- میتوانیم از تکرار شدن کد جلوگیری کنیم.
- کد قابلیت استفاده مجدد و ماژولار بودن پیدا میکند.
اندروید نماها را چگونه رسم میکند؟
در این بخش به توضیح روش اندروید برای رسم نماها میپردازیم. در آغاز سه مرحله وجود دارند که پیش از نمایش نهایی نما روی صفحه باید اجرا شوند. این سه مرحله، اندازهگیری، طرحبندی و رسم هستند. هر یک از مراحل به صورت پیمایش «عمق-اول» (depth-first) سلسلهمراتب نما است که از والد به سمت فرزند حرکت میکند. در هر مرحله متدی وجود دارند که میتواند بسته به نیازها override شده و تغییر یابد. این فرایند را میتوان به دو مرحله تقسیم کرد:
- مرحله اندازهگیری و طرحبندی
- مرحله رسم
مرحله اندازهگیری و طرحبندی
در این مرحله، این فرصت را داریم که به سیستم اندروید اعلام کنیم میخواهیم اندازه نمای سفارش چه قدر باشد و این اندازه به محدودیتهای تعیین شده از سوی والد بستگی دارد.
نمودار شمارهگذاری شده زیر شیوه اندازهگیری هر نما را در هر گام نمایش میدهد:
نمای فرزند اقدام به تعریف LayoutParams به صورت برنامهنویسی شده و یا در XML میکند و والد این مقادیر را با استفاده از متد ()getLayoutParams بازیابی میکند.
والد، MeasureSpecs را محاسبه کرده و آن را با استفاده از ()child.measure در سلسلهمراتب به سمت پایین ارسال میکند. Measurespecs شامل حالت و مقدار است.
سه حالت برای اندازهگیری وجود دارند:
- EXACTLY – یک اندازه دقیق مانند تنظیم عرض/ارتفاع برابر با 50 dp یا match_parent استفاده میشود.
- AT_MOST – والد اندازه بیشینهای که فرزند میتواند اتخاذ کند را تعیین میکند. این حالتی است که هنگام تعیین عرض/ارتفاع برابر با wrap_content استفاده میشود.
- UNSPECIFIED – اندازه مشخصی وجود ندارد و فرزند در مورد اندازه آزادانه عمل میکند.
متد ()onMeasure به همراه پارامترهای MeasureSpecs فراخوانی میشود. در این متد View اقدام به محاسبه عرض/ارتفاع مطلوب میکند و آن را با استفاده از setMeasuredDimension تنظیم میکند. به خاطر داشته باشید که متد setMeasuredDimension باید درون measure فراخوانی چون در غیر این صورت موجب بروز استثنای زمان اجرا میشود.
مرحله بعد و آخر مرحله طرحبندی است. در این مرحله، والد ()child.layout را فراخوانی کرده و اندازه نهایی و موقعیت فرزند را تعیین میکند. زمانی که نمای سفارشی را پیادهسازی میکنید، در صورتی که نمای شما، نماهای فرعی دیگری داشته باشد، باید تنها متد ()onLayout را override کنید.
در نتیجه فرایند اندازهگیری مانند یک مذاکره بین یک والد و فرزند است. فرزند، عرض و ارتفاع مطلوب را محاسبه میکند اما والد کسی است که فراخوانی نهایی را برای تنظیم موقعیت و اندازه فرزندش انجام میدهد.
مرحله رسم
آخرین و مهمترین مرحله در رسم نمای سفارشی override کردن متد ()onDraw است. بوم یک کلاس مبنا است که متدهای زیادی برای رسم متن، bitmap-ها، خطوط و دیگر اشکال ابتدایی گرافیکی عرضه کرده است.
هر والد، خود را رسم میکند و سپس تقاضا میکند که هر فرزند نیز همان کار را انجام دهد. یک اثر جنبی جالب در زمانی است که والد ابتدا خود را رسم میکند و در انتها فرزندانش را روی خود رسم کرده و در واقع خود را میپوشاند.
ایجاد نمای سفارشی
اکنون نوبت بخشی رسیده است که مدتها منتظر آن بودیم و آن کدنویسی است. در ادامه به بررسی روش ایجاد یک نمای سفارشی با استفاده از «کاتلین» (Kotlin) میپردازیم. ما جهت مقاصد آموزشی یک شاخص باتری رسم میکنیم تا وضعیت کنونی باتری دستگاه را نمایش دهیم. نمودار زیر وضعیتهای مختلف یک باتری را نشان میدهد:
برای ایجاد یک نمای BatteryMeterView باید مراحل زیر را طی کنیم. یک پروژه اندروید استودیو ایجاد کرده و یک کلاس جدید به نام BatteryMeterView اضافه کنید.
آن را با استفاده از کلاس View بسط دهید و سازندهها را طوری اضافه کنید که با super مطابقت داشته باشند.
1class BatteryMeterView @JvmOverloads constructor(
2 context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
3): View(context, attrs, defStyleAttr)
ما برای آمادهسازی رسم برخی شیءهای paint، رنگ و شکلها را اعلان میکنیم.
View-ی ما همانند یک ویجت ابتدایی باید مقداری آمادهسازی اولیه داشته باشد و همه مشخصهها نوعی مقدار پیشفرض داشته باشند. در ادامه یک شیء همراه درون BatteryMeterView ایجاد کرده و برخی ثابتها را به آن اضافه میکنیم.
1companion object {
2 private const val DEFAULT_CHARGING_STATE = false
3 private const val DEFAULT_BATTERY_LEVEL = 70
4 private const val DEFAULT_WARNING_LEVEL = 30
5 private const val DEFAULT_BATTERY_LEVEL_COLOR = Color.GREEN
6 private const val DEFAULT_WARNING_COLOR = Color.RED
7 private const val DEFAULT_BACKGROUND_COLOR = Color.LTGRAY
8 private const val DEFAULT_BATTERY_HEAD_COLOR = Color.DKGRAY
9 private const val DEFAULT_TEXT_COLOR = Color.DKGRAY
10 private const val DEFAULT_CHARGING_COLOR = Color.DKGRAY
11 private const val TEXT_SIZE_RATIO = 0.5f
12}
13
14// Battery dimensions
15private var contentHeight: Int = 0
16private var contentWidth: Int = 0
17private var batteryHeadWidth = 0
18private var mainContentOffset: Int = 20
19
20// Shapes
21private val backgroundRect: Rect = Rect()
22
23private val batteryLevelRect: Rect = Rect()
24
25private val batteryHeadRect: Rect = Rect()
26
27private val chargingLogoPath: Path = Path()
28
29// Paints
30private val backgroundPaint: Paint = Paint(ANTI_ALIAS_FLAG)
31
32private val backgroundPaintStroke: Paint = Paint(ANTI_ALIAS_FLAG)
33
34private val textValuePaint: Paint = Paint(ANTI_ALIAS_FLAG)
35
36private val batteryHeadPaint: Paint = Paint(ANTI_ALIAS_FLAG)
37
38private val batteryLevelPaint: Paint = Paint(ANTI_ALIAS_FLAG)
39
40private val chargingLogoPaint: Paint = Paint(ANTI_ALIAS_FLAG)
41
42// Colors
43var batteryLevelColor: Int = DEFAULT_BATTERY_LEVEL_COLOR
44 set(@ColorInt color) {
45 field = color
46 batteryLevelPaint.color = color
47 invalidate()
48}
49
50var warningColor: Int = DEFAULT_WARNING_COLOR
51 set(@ColorInt color) {
52 field = color
53 batteryLevelPaint.color = color
54 invalidate()
55}
56
57var backgroundRectColor: Int = DEFAULT_BACKGROUND_COLOR
58 set(@ColorInt color) {
59 field = color
60 backgroundPaint.color = color
61 invalidate()
62}
63
64var batteryHeadColor: Int = DEFAULT_BATTERY_HEAD_COLOR
65 set(@ColorInt color) {
66 field = color
67 batteryHeadPaint.color = color
68 invalidate()
69}
70
71var chargingColor: Int = DEFAULT_CHARGING_COLOR
72 set(@ColorInt color) {
73 field = color
74 chargingLogoPaint.color = color
75 invalidate()
76}
77
78var textColor: Int = DEFAULT_TEXT_COLOR
79 set(@ColorInt color) {
80 field = color
81 textValuePaint.color = color
82 invalidate()
83}
84
85init {
86 parseAttr(attrs)
87
88 batteryLevelPaint.apply {
89 style = Paint.Style.FILL
90 color = batteryLevelColor
91 }
92
93 backgroundPaint.apply {
94 style = Paint.Style.FILL
95 color = backgroundRectColor
96 }
97
98 backgroundPaintStroke.apply {
99 style = Paint.Style.STROKE
100 strokeWidth = 20f
101 color = Color.BLACK
102 }
103
104
105 batteryHeadPaint.apply {
106 style = Paint.Style.FILL
107 color = batteryHeadColor
108 }
109
110 chargingLogoPaint.apply {
111 style = Paint.Style.FILL_AND_STROKE
112 color = chargingColor
113 strokeWidth = 5f
114 }
115
116 textValuePaint.apply {
117 textAlign = Paint.Align.CENTER
118 color = textColor
119 }
120}
پیش از رسم باتری روی صفحه، باید اندازه و موقعیت آن را بهروزرسانی کنیم. بهترین مکان برای مدیریت هر گونه تغییر اندازه درون متد onSizeChanged است. به این منظور مراحل زیر را طی کنید:
- عرض و ارتفاع محتوا را تنظیم کنید.
- اندازه متن مقدار باتری را به اندازه نصف ارتفاع محتوا تنظیم کنید.
- عرض سر باتری را 1/12 کل عرض باتری تنظیم کنید.
- موقعیت rect پسزمینه را تنظیم کنید.
- موقعیت rect سر باتری را تنظیم کنید.
- موقعیت rext سطح باتری را تنظیم کنید.
نکته: با توجه به مقاصد آموزشی این مقاله از برخی مقادیر ثابت برای حاشیه و آفست محتوا استفاده کردهایم.
1override fun onSizeChanged(width: Int, height: Int, oldw: Int, oldh: Int) {
2 contentWidth = width - paddingLeft - paddingRight
3 contentHeight = height - paddingTop - paddingBottom
4 textValuePaint.textSize = contentHeight * TEXT_SIZE_RATIO
5 batteryHeadWidth = (1f / 12f * contentWidth).toInt()
6 backgroundRect.set(
7 15,
8 15,
9 contentWidth - batteryHeadWidth - 15,
10 contentHeight - 15
11 )
12 batteryHeadRect.set(
13 backgroundRect.right + 20,
14 backgroundRect.top + contentHeight / 4,
15 backgroundRect.left + contentWidth - 20,
16 backgroundRect.top + contentHeight * 3 / 4
17 )
18 batteryLevelRect.set(
19 backgroundRect.left + mainContentOffset,
20 backgroundRect.top + mainContentOffset,
21 ((backgroundRect.right - mainContentOffset) *
22 (this.batteryLevel.toDouble() / 100.toDouble())).toInt(),
23 backgroundRect.bottom - mainContentOffset
24 )
25}
اینک برای رسم BatteryMeter شروع به override کردن متد ()onDraw میکنیم.
- پسزمینه نما را رسم میکنیم.
- سر باتری را رسم میکنیم.
- کانتینری که سطح باتری در آن قرار میگیرد را رسم میکنیم.
- اکنون اگر باتری تغییر پیدا کند، یک لوگوی تغییر نمایش میدهیم و در غیر این صورت متن مقدار کنونی باتری را نمایش میدهیم.
به خاطر بسپارید که متد onDraw با بسامد 60 بار در ثانیه (fps 60) فراخوانی میشود و قرار دادن هر نوع عملیات محاسباتی سنگین و ایجاد شیء درون آن میتواند موجب کاهش عملکرد اپلیکیشن شود. برای اجتناب از این وضعیت، میتوانیم همه اشیا را درون سازندهها بسازیم و در صورت نیاز میتوانیم مشخصهها را در ادامه تغییر دهیم.
1override fun onDraw(canvas: Canvas) {
2 // Draw the background body of battery view
3 drawBackground(canvas)
4
5 // Draw the head of battery
6 drawBatteryHead(canvas)
7
8 // Draw the current battery level
9 drawBatteryLevel(canvas)
10
11 if (isCharging) {
12 drawChargingLogo(canvas)
13 } else {
14 drawCurrentBatteryValueText(canvas)
15 }
16}
17
18private fun drawBackground(canvas: Canvas) {
19 canvas.drawRect(backgroundRect, backgroundPaint)
20 canvas.drawRoundRect(RectF(backgroundRect), 50f, 50f, backgroundPaintStroke)
21}
22
23private fun drawBatteryHead(canvas: Canvas) {
24 // Draw the head of battery view
25 canvas.drawRoundRect(RectF(batteryHeadRect), 10f, 10f, batteryHeadPaint)
26}
27
28private fun drawBatteryLevel(canvas: Canvas) {
29 if (batteryLevel <= warningLevel) {
30 batteryLevelPaint.color = warningColor
31 } else {
32 batteryLevelPaint.color = batteryLevelColor
33 }
34
35 if (batteryLevel == 0) {
36 drawEmptyText(canvas)
37 } else {
38 canvas.drawRoundRect(RectF(batteryLevelRect), 25f, 25f, batteryLevelPaint)
39 }
40}
41
42private fun drawChargingLogo(canvas: Canvas) {
43 VectorDrawableCompat.create(
44 context.resources,
45 R.drawable.ic_charging_bolt,
46 null
47 )?.apply {
48 setBounds(
49 backgroundRect.left + contentWidth/4,
50 backgroundRect.top + contentHeight/4,
51 backgroundRect.right - contentWidth/4,
52 backgroundRect.bottom - contentHeight/4
53 )
54 setColorFilter(chargingColor,PorterDuff.Mode.SRC_IN)
55 draw(canvas)
56 }
57}
58
59private fun drawCurrentBatteryValueText(canvas: Canvas) {
60 val text = if (batteryLevel == 0) "Empty" else batteryLevel.toString()
61 canvas.drawText(
62 text,
63 (contentWidth * 0.45).toFloat(),
64 (contentHeight * 0.7).toFloat(),
65 textValuePaint
66 )
67}
68
69private fun drawEmptyText(canvas: Canvas) {
70 canvas.drawText(
71 "Empty",
72 (contentWidth * 0.45).toFloat(),
73 (contentHeight * 0.7).toFloat(),
74 textValuePaint
75 )
76}
اکنون برای این که به باتری خود امکان تغییر در زمان اجرا بدهیم، باید متد ()invalidate را هر بار که حالت نما بهروزرسانی میشود فراخوانی کنیم. کاری که invalidate انجام میدهد این است که به اندروید اجازه میدهد بداند که نما خراب شده و نیاز به ترسیم مجدد دارد. لازم به ذکر است که باید مراقب باشید زیرا فراخوانی ()invalidate به تعداد زیاد موجب بروز مشکلاتی میشود.
1var isCharging: Boolean = DEFAULT_CHARGING_STATE
2 @CheckResult
3 get() = field
4 set(value) {
5 field = value
6 invalidate()
7}
8
9var batteryLevel: Int = DEFAULT_BATTERY_LEVEL
10 @CheckResult
11 get() = field
12 set(level) {
13 field = when {
14 level > 100 -> 100
15 level < 0 -> 0
16 else -> level
17 }
18 if (field <= warningLevel) {
19 fillPaint.color = warningFillColor
20 } else {
21 fillPaint.color = normalFillColor
22 }
23 invalidate()
24}
مرحله نهایی، افزودن نمای باتری به Layout است:
1<ba.rubicon.widget.BatteryMeterView android:layout_width="200dp"
2 android:layout_height="90dp"
3 app:battery_level="30"
4 android:layout_gravity="center"
5 app:charging="true"/>
به این ترتیب کار به پایان میرسد و ما اینک یک باتری سنج داریم که خودمان ایجاد کردهایم. برای مشاهده کد کامل پروژه به این لینک (+) مراجعه کنید.
مزایا و معایب پیادهسازی نماهای سفارشی
پیش از جمعبندی این مقاله بهتر است مزایا و معایب پیادهسازی نماهای سفارشی را نیز مورد اشاره قرار دهیم. همانند هر فرایند پیادهسازی دیگری، همواره مزیتها و معایبی وجود دارند، اما این موارد نباید شما را از امتحان کردن آن نا امید کنند.
مزایا
ظاهر/رفتار سفارشی: نمای سفارشی = سفارشیسازی. پلتفرم اندروید گسترده است، اما موارد خاصی وجود دارند که قابلیتهای نماهای اندروید نیازهای شما را برآورده نمیسازند و از این رو نمای سفارشی این فرصت را به شما میدهد که چیزی بسازید که متعلق به شما است. زمانی که نوبت به طراحی و تعامل میرسد، کنترل کاملی دارید زیرا نمای سفارشی گزینههای بینهایتی ارائه میکند.
قابلیت استفاده مجدد/قابلیت خوانایی: زمانی که اپلیکیشنهای بزرگمقیاس توسعه میدهید، قابلیت استفاده مجدد کد همواره موضوعی مورد علاقه است. زمانی که یک نمای سفارشی ایجاد میکنید، میتوانید به سادگی آن را در چند جای دیگر نیز در اپلیکیشن خود مورد استفاده قرار دهید.
عملکرد: در برخی موارد خاص، ساختن نمای سفارشی میتواند موجب مقداری بهبود عملکرد شود.
معایب
در این بخش برخی معایب نماهای سفارشی را بررسی میکنیم.
زمان و تلاش: ایجاد نماهای سفارشی کاری زمانبر است و استفاده از آن قطعاً میتواند دشوار باشد تا اینکه به کارکرد آنها آشنا شوید.
پشتکار: چند چیز وجود دارند که در زمان پیادهسازی نماهای سفارشی باید آگاه باشید. اول این که باید مطمئن باشید فونت، اندازه متن، رنگ، سایهها، هایلایت و استایل را به درستی مدیریت کردهاید. همچنین باید مطمئن شوید که نما روی همه تراکمهای نمایشی به درستی کار میکند چون زیرکلاس بوم اندروید برحسب پیکسل رسم میشود و نه DP. اگر با تصاویر کار میکنید باید به خاطر داشته باشید که نسبت تصویر، بزرگنمایی و مقیاسبندی درستی داشته باشد.
موارد زیادی که باید مدیریت شوند: همچنین باید همه انواع شنوندههای کلیک، تعاملهای کاربر، کلیکهای منفرد، دو بار کلیک، فشردن طولانی، سوایپ کردن و جابجایی را مدیریت کنید.
اگر این مطلب برای شما مفید بوده است، آموزشهای زیر نیز به شما پیشنهاد میشوند:
- آموزش Canvas در HTML — به زبان ساده و گام به گام
- مجموعه آموزشهای برنامهنویسی اندروید
- مجموعه آموزشهای برنامهنویسی
- گنجینه برنامهنویسی اندروید (Android)
- ۵ گام ضروری برای یادگیری برنامهنویسی اندروید — راهنمای جامع
- اپلیکیشن آنی اندروید (Android Instant App) چیست؟ — از صفر تا صد
==
ممنون
وقت گذاشتین این اموزش تهیه کردین
بعد از ران کردن برنامه این ویو به صورت ایستا بود و با وضعیت باتری دستگاه یکی نبود !
برای ست کردن با وضعیت باتری باید چی کار کنیم ، لطفا راهنمایی بکنید.