ایجاد نمای سفارشی (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 مطابقت داشته باشند.

class BatteryMeterView @JvmOverloads constructor(
	context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
): View(context, attrs, defStyleAttr)

ما برای آماده‌سازی رسم برخی شیءهای paint، رنگ و شکل‌ها را اعلان می‌کنیم.

View-ی ما همانند یک ویجت ابتدایی باید مقداری آماده‌سازی اولیه داشته باشد و همه مشخصه‌ها نوعی مقدار پیش‌فرض داشته باشند. در ادامه یک شیء همراه درون BatteryMeterView ایجاد کرده و برخی ثابت‌ها را به آن اضافه می‌کنیم.

companion object {
    private const val DEFAULT_CHARGING_STATE = false
    private const val DEFAULT_BATTERY_LEVEL = 70
    private const val DEFAULT_WARNING_LEVEL = 30
    private const val DEFAULT_BATTERY_LEVEL_COLOR = Color.GREEN
    private const val DEFAULT_WARNING_COLOR = Color.RED
    private const val DEFAULT_BACKGROUND_COLOR = Color.LTGRAY
    private const val DEFAULT_BATTERY_HEAD_COLOR = Color.DKGRAY
    private const val DEFAULT_TEXT_COLOR = Color.DKGRAY
    private const val DEFAULT_CHARGING_COLOR = Color.DKGRAY
    private const val TEXT_SIZE_RATIO = 0.5f
}

​// Battery dimensions
private var contentHeight: Int = 0
private var contentWidth: Int = 0
private var batteryHeadWidth = 0
private var mainContentOffset: Int = 20

// Shapes
private val backgroundRect: Rect = Rect()

private val batteryLevelRect: Rect = Rect()

private val batteryHeadRect: Rect = Rect()

private val chargingLogoPath: Path = Path()

// Paints
private val backgroundPaint: Paint = Paint(ANTI_ALIAS_FLAG)

private val backgroundPaintStroke: Paint = Paint(ANTI_ALIAS_FLAG)

private val textValuePaint: Paint = Paint(ANTI_ALIAS_FLAG)

private val batteryHeadPaint: Paint = Paint(ANTI_ALIAS_FLAG)

private val batteryLevelPaint: Paint = Paint(ANTI_ALIAS_FLAG)

private val chargingLogoPaint: Paint = Paint(ANTI_ALIAS_FLAG)

// Colors
var batteryLevelColor: Int = DEFAULT_BATTERY_LEVEL_COLOR
    set(@ColorInt color) {
    field = color
    batteryLevelPaint.color = color
    invalidate()
}

var warningColor: Int = DEFAULT_WARNING_COLOR
    set(@ColorInt color) {
    field = color
    batteryLevelPaint.color = color
    invalidate()
}

var backgroundRectColor: Int = DEFAULT_BACKGROUND_COLOR
    set(@ColorInt color) {
    field = color
    backgroundPaint.color = color
    invalidate()
}

var batteryHeadColor: Int = DEFAULT_BATTERY_HEAD_COLOR
    set(@ColorInt color) {
    field = color
    batteryHeadPaint.color = color
    invalidate()
}

var chargingColor: Int = DEFAULT_CHARGING_COLOR
    set(@ColorInt color) {
    field = color
    chargingLogoPaint.color = color
    invalidate()
}

var textColor: Int = DEFAULT_TEXT_COLOR
    set(@ColorInt color) {
    field = color
    textValuePaint.color = color
    invalidate()
}

init {
    parseAttr(attrs)

    batteryLevelPaint.apply {
        style = Paint.Style.FILL
        color = batteryLevelColor
    }

    backgroundPaint.apply {
        style = Paint.Style.FILL
        color = backgroundRectColor
    }

    backgroundPaintStroke.apply {
        style = Paint.Style.STROKE
        strokeWidth = 20f
        color = Color.BLACK
    }


    batteryHeadPaint.apply {
        style = Paint.Style.FILL
        color = batteryHeadColor
    }

    chargingLogoPaint.apply {
        style = Paint.Style.FILL_AND_STROKE
        color = chargingColor
        strokeWidth = 5f
    }

    textValuePaint.apply {
        textAlign = Paint.Align.CENTER
        color = textColor
    }
}

پیش از رسم باتری روی صفحه، باید اندازه و موقعیت آن را به‌روزرسانی کنیم. بهترین مکان برای مدیریت هر گونه تغییر اندازه درون متد onSizeChanged است. به این منظور مراحل زیر را طی کنید:

  • عرض و ارتفاع محتوا را تنظیم کنید.
  • اندازه متن‌ مقدار باتری را به اندازه نصف ارتفاع محتوا تنظیم کنید.
  • عرض سر باتری را 1/12 کل عرض باتری تنظیم کنید.
  • موقعیت rect پس‌زمینه را تنظیم کنید.
  • موقعیت rect سر باتری را تنظیم کنید.
  • موقعیت rext سطح باتری را تنظیم کنید.

نکته: با توجه به مقاصد آموزشی این مقاله از برخی مقادیر ثابت برای حاشیه و آفست محتوا استفاده کرده‌ایم.

override fun onSizeChanged(width: Int, height: Int, oldw: Int, oldh: Int) {
    contentWidth = width - paddingLeft - paddingRight
    contentHeight = height - paddingTop - paddingBottom
    textValuePaint.textSize = contentHeight * TEXT_SIZE_RATIO
    batteryHeadWidth = (1f / 12f * contentWidth).toInt()
    backgroundRect.set(
        15,
        15,
        contentWidth - batteryHeadWidth - 15,
        contentHeight - 15
    )
    batteryHeadRect.set(
        backgroundRect.right + 20,
        backgroundRect.top + contentHeight / 4,
        backgroundRect.left + contentWidth - 20,
        backgroundRect.top + contentHeight * 3 / 4
    )
    batteryLevelRect.set(
        backgroundRect.left + mainContentOffset,
        backgroundRect.top + mainContentOffset,
        ((backgroundRect.right - mainContentOffset) *
        (this.batteryLevel.toDouble() / 100.toDouble())).toInt(),
        backgroundRect.bottom - mainContentOffset
    )
}

اینک برای رسم BatteryMeter شروع به override کردن متد ()onDraw می‌کنیم.

  1. پس‌زمینه نما را رسم می‌کنیم.
  2. سر باتری را رسم می‌کنیم.
  3. کانتینری که سطح باتری در آن قرار می‌گیرد را رسم می‌کنیم.
  4. اکنون اگر باتری تغییر پیدا کند، یک لوگوی تغییر نمایش می‌دهیم و در غیر این صورت متن مقدار کنونی باتری را نمایش می‌دهیم.

به خاطر بسپارید که متد onDraw با بسامد 60 بار در ثانیه (fps 60) فراخوانی می‌شود و قرار دادن هر نوع عملیات محاسباتی سنگین و ایجاد شیء درون آن می‌تواند موجب کاهش عملکرد اپلیکیشن شود. برای اجتناب از این وضعیت، می‌توانیم همه اشیا را درون سازنده‌ها بسازیم و در صورت نیاز می‌توانیم مشخصه‌ها را در ادامه تغییر دهیم.

override fun onDraw(canvas: Canvas) {
    // Draw the background body of battery view
    drawBackground(canvas)

    // Draw the head of battery
    drawBatteryHead(canvas)

    // Draw the current battery level
    drawBatteryLevel(canvas)

    if (isCharging) {
        drawChargingLogo(canvas)
    } else {
        drawCurrentBatteryValueText(canvas)
    }
}

private fun drawBackground(canvas: Canvas) {
    canvas.drawRect(backgroundRect, backgroundPaint)
    canvas.drawRoundRect(RectF(backgroundRect), 50f, 50f, backgroundPaintStroke)
}

private fun drawBatteryHead(canvas: Canvas) {
    // Draw the head of battery view
    canvas.drawRoundRect(RectF(batteryHeadRect), 10f, 10f, batteryHeadPaint)
}

private fun drawBatteryLevel(canvas: Canvas) {
    if (batteryLevel <= warningLevel) {
        batteryLevelPaint.color = warningColor
    } else {
        batteryLevelPaint.color = batteryLevelColor
    }

    if (batteryLevel == 0) {
        drawEmptyText(canvas)
    } else {
        canvas.drawRoundRect(RectF(batteryLevelRect), 25f, 25f, batteryLevelPaint)
    }
}

private fun drawChargingLogo(canvas: Canvas) {
    VectorDrawableCompat.create(
        context.resources,
        R.drawable.ic_charging_bolt,
        null
    )?.apply {
        setBounds(
            backgroundRect.left + contentWidth/4,
            backgroundRect.top + contentHeight/4,
            backgroundRect.right - contentWidth/4,
            backgroundRect.bottom - contentHeight/4
        )
        setColorFilter(chargingColor,PorterDuff.Mode.SRC_IN)
        draw(canvas)
    }
}

private fun drawCurrentBatteryValueText(canvas: Canvas) {
    val text = if (batteryLevel == 0) "Empty" else batteryLevel.toString()
    canvas.drawText(
    text,
    (contentWidth * 0.45).toFloat(),
    (contentHeight * 0.7).toFloat(),
    textValuePaint
    )
}

private fun drawEmptyText(canvas: Canvas) {
    canvas.drawText(
        "Empty",
        (contentWidth * 0.45).toFloat(),
        (contentHeight * 0.7).toFloat(),
        textValuePaint
    )
}

اکنون برای این که به باتری خود امکان تغییر در زمان اجرا بدهیم، باید متد ()invalidate را هر بار که حالت نما به‌روزرسانی می‌شود فراخوانی کنیم. کاری که invalidate انجام می‌دهد این است که به اندروید اجازه می‌دهد بداند که نما خراب شده و نیاز به ترسیم مجدد دارد. لازم به ذکر است که باید مراقب باشید زیرا فراخوانی ()invalidate به تعداد زیاد موجب بروز مشکلاتی می‌شود.

var isCharging: Boolean = DEFAULT_CHARGING_STATE
    @CheckResult
    get() = field
    set(value) {
    field = value
    invalidate()
}

var batteryLevel: Int = DEFAULT_BATTERY_LEVEL
    @CheckResult
    get() = field
    set(level) {
        field = when {
        level > 100 -> 100
        level < 0 -> 0
        else -> level
    }
    if (field <= warningLevel) {
        fillPaint.color = warningFillColor
    } else {
        fillPaint.color = normalFillColor
    }
    invalidate()
}

مرحله نهایی، افزودن نمای باتری به Layout است:

<ba.rubicon.widget.BatteryMeterView android:layout_width="200dp"
    android:layout_height="90dp"
    app:battery_level="30"
    android:layout_gravity="center"
    app:charging="true"/>

به این ترتیب کار به پایان می‌رسد و ما اینک یک باتری سنج داریم که خودمان ایجاد کرده‌ایم. برای مشاهده کد کامل پروژه به این لینک (+) مراجعه کنید.

مزایا و معایب پیاده‌سازی نماهای سفارشی

پیش از جمع‌بندی این مقاله بهتر است مزایا و معایب پیاده‌سازی نماهای سفارشی را نیز مورد اشاره قرار دهیم. همانند هر فرایند پیاده‌سازی دیگری، همواره مزیت‌ها و معایبی وجود دارند، اما این موارد نباید شما را از امتحان کردن آن نا امید کنند.

مزایا

ظاهر/رفتار سفارشی: نمای سفارشی = سفارشی‌سازی. پلتفرم اندروید گسترده است، اما موارد خاصی وجود دارند که قابلیت‌های نماهای اندروید نیازهای شما را برآورده نمی‌سازند و از این رو نمای سفارشی این فرصت را به شما می‌دهد که چیزی بسازید که متعلق به شما است. زمانی که نوبت به طراحی و تعامل می‌رسد، کنترل کاملی دارید زیرا نمای سفارشی گزینه‌های بی‌نهایتی ارائه می‌کند.

قابلیت استفاده مجدد/قابلیت خوانایی: زمانی که اپلیکیشن‌های بزرگ‌مقیاس توسعه می‌دهید، قابلیت استفاده مجدد کد همواره موضوعی مورد علاقه است. زمانی که یک نمای سفارشی ایجاد می‌کنید، می‌توانید به سادگی آن را در چند جای دیگر نیز در اپلیکیشن خود مورد استفاده قرار دهید.

عملکرد: در برخی موارد خاص، ساختن نمای سفارشی می‌تواند موجب مقداری بهبود عملکرد شود.

معایب

در این بخش برخی معایب نماهای سفارشی را بررسی می‌کنیم.

زمان و تلاش: ایجاد نماهای سفارشی کاری زمان‌بر است و استفاده از آن قطعاً می‌تواند دشوار باشد تا اینکه به کارکرد آن‌ها آشنا شوید.

پشتکار: چند چیز وجود دارند که در زمان پیاده‌سازی نماهای سفارشی باید آگاه باشید. اول این که باید مطمئن باشید فونت، اندازه متن، رنگ، سایه‌ها، هایلایت و استایل را به درستی مدیریت کرده‌اید. همچنین باید مطمئن شوید که نما روی همه تراکم‌های نمایشی به درستی کار می‌کند چون زیرکلاس بوم اندروید برحسب پیکسل رسم می‌شود و نه DP. اگر با تصاویر کار می‌کنید باید به خاطر داشته باشید که نسبت تصویر، بزرگنمایی و مقیاس‌بندی درستی داشته باشد.

موارد زیادی که باید مدیریت شوند: همچنین باید همه انواع شنونده‌های کلیک، تعامل‌های کاربر، کلیک‌های منفرد، دو بار کلیک، فشردن طولانی، سوایپ کردن و جابجایی را مدیریت کنید.

اگر این مطلب برای شما مفید بوده است، آموزش‌های زیر نیز به شما پیشنهاد می‌شوند:

==

بر اساس رای ۰ نفر
آیا این مطلب برای شما مفید بود؟
شما قبلا رای داده‌اید!
اگر بازخوردی درباره این مطلب دارید یا پرسشی دارید که بدون پاسخ مانده است، آن را از طریق بخش نظرات مطرح کنید.
منابع:
rubicon-stories
One thought on “ایجاد نمای سفارشی (Custom View) در اندروید — از صفر تا صد

ممنون
وقت گذاشتین این اموزش تهیه کردین
بعد از ران کردن برنامه این ویو به صورت ایستا بود و با وضعیت باتری دستگاه یکی نبود !
برای ست کردن با وضعیت باتری باید چی کار کنیم ، لطفا راهنمایی بکنید.

نظر شما چیست؟

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *