یک طراحی سفارشی ایجاد کنید

روش نوشتن را امتحان کنید
Jetpack Compose ابزار رابط کاربری پیشنهادی برای اندروید است. یاد بگیرید که چگونه با طرح‌بندی‌ها در Compose کار کنید.

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

برای اطلاعات بیشتر، به نمای کلی Drawables مراجعه کنید.

تابع onDraw() را نادیده بگیرید

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

با ایجاد یک شیء Paint شروع کنید. بخش بعدی Paint با جزئیات بیشتری مورد بحث قرار می‌دهد.

ایجاد اشیاء ترسیمی

چارچوب android.graphics طراحی را به دو بخش تقسیم می‌کند:

  • چه چیزی باید کشیده شود، توسط Canvas مدیریت می‌شود.
  • نحوه طراحی، توسط Paint انجام می‌شود.

برای مثال، Canvas متدی برای رسم خط ارائه می‌دهد و Paint متدهایی برای تعریف رنگ آن خط. Canvas متدی برای رسم مستطیل دارد و Paint تعریف می‌کند که آیا آن مستطیل را با رنگ پر کنید یا خالی بگذارید. Canvas شکل‌هایی را که می‌توانید روی صفحه بکشید تعریف می‌کند و Paint رنگ، سبک، فونت و غیره هر شکلی را که رسم می‌کنید تعریف می‌کند.

قبل از اینکه چیزی بکشید، یک یا چند شیء Paint ایجاد کنید. مثال زیر این کار را در متدی به نام init انجام می‌دهد. این متد از سازنده در جاوا فراخوانی می‌شود، اما در کاتلین می‌توان آن را به صورت درون‌خطی مقداردهی اولیه کرد.

کاتلین

@ColorInt
private var textColor    // Obtained from style attributes.

@Dimension
private var textHeight   // Obtained from style attributes.

private val textPaint = Paint(ANTI_ALIAS_FLAG).apply {
    color = textColor
    if (textHeight == 0f) {
        textHeight = textSize
    } else {
        textSize = textHeight
    }
}

private val piePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
    style = Paint.Style.FILL
    textSize = textHeight
}

private val shadowPaint = Paint(0).apply {
    color = 0x101010
    maskFilter = BlurMaskFilter(8f, BlurMaskFilter.Blur.NORMAL)
}

جاوا

private Paint textPaint;
private Paint piePaint;
private Paint shadowPaint;

@ColorInt
private int textColor;       // Obtained from style attributes.

@Dimension
private float textHeight;    // Obtained from style attributes.

private void init() {
   textPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
   textPaint.setColor(textColor);
   if (textHeight == 0) {
       textHeight = textPaint.getTextSize();
   } else {
       textPaint.setTextSize(textHeight);
   }

   piePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
   piePaint.setStyle(Paint.Style.FILL);
   piePaint.setTextSize(textHeight);

   shadowPaint = new Paint(0);
   shadowPaint.setColor(0xff101010);
   shadowPaint.setMaskFilter(new BlurMaskFilter(8, BlurMaskFilter.Blur.NORMAL));
   ...
}

ایجاد اشیاء از قبل، یک بهینه‌سازی مهم است. نماها مرتباً دوباره ترسیم می‌شوند و بسیاری از اشیاء ترسیمی نیاز به مقداردهی اولیه‌ی پرهزینه‌ای دارند. ایجاد اشیاء ترسیمی در متد onDraw() به طور قابل توجهی عملکرد را کاهش می‌دهد و می‌تواند رابط کاربری شما را کند کند.

مدیریت رویدادهای طرح‌بندی

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

اگرچه View متدهای زیادی برای مدیریت اندازه‌گیری دارد، اما اکثر آنها نیازی به بازنویسی ندارند. اگر View شما به کنترل خاصی روی اندازه‌اش نیاز ندارد، فقط یک متد را بازنویسی کنید: onSizeChanged() .

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

وقتی به نمای شما اندازه‌ای اختصاص داده می‌شود، مدیر طرح‌بندی فرض می‌کند که این اندازه شامل فاصله‌ی بین عناصر (padding) نما نیز می‌شود. هنگام محاسبه‌ی اندازه‌ی نمای خود، مقادیر فاصله‌ی بین عناصر را مدیریت کنید. در اینجا قطعه کدی از onSizeChanged() آمده است که نحوه‌ی انجام این کار را نشان می‌دهد:

کاتلین

private val showText    // Obtained from styled attributes.
private val textWidth   // Obtained from styled attributes.

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)
    // Account for padding.
    var xpad = (paddingLeft + paddingRight).toFloat()
    val ypad = (paddingTop + paddingBottom).toFloat()

    // Account for the label.
    if (showText) xpad += textWidth.toFloat()
    val ww = w.toFloat() - xpad
    val hh = h.toFloat() - ypad

    // Figure out how big you can make the pie.
    val diameter = Math.min(ww, hh)
}

جاوا

private Boolean showText;    // Obtained from styled attributes.
private int textWidth;       // Obtained from styled attributes.

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    // Account for padding.
    float xpad = (float)(getPaddingLeft() + getPaddingRight());
    float ypad = (float)(getPaddingTop() + getPaddingBottom());

    // Account for the label.
    if (showText) xpad += textWidth;

    float ww = (float)w - xpad;
    float hh = (float)h - ypad;

    // Figure out how big you can make the pie.
    float diameter = Math.min(ww, hh);
}

اگر به کنترل دقیق‌تری روی پارامترهای طرح‌بندی نمای خود نیاز دارید، onMeasure() را پیاده‌سازی کنید. پارامترهای این متد، مقادیر View.MeasureSpec هستند که به شما می‌گویند والد نمای شما می‌خواهد نمای شما چقدر بزرگ باشد و آیا این اندازه یک حداکثر قطعی است یا فقط یک پیشنهاد. به عنوان یک بهینه‌سازی، این مقادیر به صورت اعداد صحیح فشرده ذخیره می‌شوند و شما از متدهای استاتیک View.MeasureSpec برای باز کردن اطلاعات ذخیره شده در هر عدد صحیح استفاده می‌کنید.

در اینجا یک نمونه پیاده‌سازی از onMeasure() آورده شده است. در این پیاده‌سازی، تلاش می‌شود تا مساحت نمودار به اندازه برچسب آن بزرگ باشد:

کاتلین

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    // Try for a width based on your minimum.
    val minw: Int = paddingLeft + paddingRight + suggestedMinimumWidth
    val w: Int = View.resolveSizeAndState(minw, widthMeasureSpec, 1)

    // Whatever the width is, ask for a height that lets the pie get as big as
    // it can.
    val minh: Int = View.MeasureSpec.getSize(w) - textWidth.toInt() + paddingBottom + paddingTop
    val h: Int = View.resolveSizeAndState(minh, heightMeasureSpec, 0)

    setMeasuredDimension(w, h)
}

جاوا

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
   // Try for a width based on your minimum.
   int minw = getPaddingLeft() + getPaddingRight() + getSuggestedMinimumWidth();
   int w = resolveSizeAndState(minw, widthMeasureSpec, 1);

   // Whatever the width is, ask for a height that lets the pie get as big as it
   // can.
   int minh = MeasureSpec.getSize(w) - (int)textWidth + getPaddingBottom() + getPaddingTop();
   int h = resolveSizeAndState(minh, heightMeasureSpec, 0);

   setMeasuredDimension(w, h);
}

سه نکته مهم در این کد وجود دارد که باید به آنها توجه کنید:

  • این محاسبات، فاصله‌گذاری نما (padding) را در نظر می‌گیرد. همانطور که قبلاً ذکر شد، این مسئولیت بر عهده نما است.
  • متد کمکی resolveSizeAndState() برای ایجاد مقادیر نهایی عرض و ارتفاع استفاده می‌شود. این تابع کمکی با مقایسه اندازه مورد نیاز نما با مقدار ارسالی به onMeasure() مقدار View.MeasureSpec مناسب را برمی‌گرداند.
  • متد onMeasure() هیچ مقداری را بر نمی‌گرداند. در عوض، این متد نتایج خود را با فراخوانی setMeasuredDimension() ارسال می‌کند. فراخوانی این متد اجباری است. اگر این فراخوانی را حذف کنید، کلاس View یک خطای زمان اجرا (runtime exception) ایجاد می‌کند.

قرعه کشی

بعد از اینکه کد ایجاد و اندازه‌گیری شیء خود را تعریف کردید، می‌توانید onDraw() را پیاده‌سازی کنید. هر نما onDraw() را به طور متفاوتی پیاده‌سازی می‌کند، اما برخی عملیات مشترک وجود دارد که اکثر نماها با هم دارند:

  • با استفاده از drawText() متن را رسم کنید. با فراخوانی تابع setTypeface() نوع فونت و با فراخوانی setColor() رنگ متن را مشخص کنید.
  • با استفاده از drawRect() ، drawOval() و drawArc() اشکال اولیه را رسم کنید. با فراخوانی setStyle() می‌توانید مشخص کنید که آیا اشکال پر، دور تا دور آنها خط کشیده شده یا هر دو باشند.
  • با استفاده از کلاس Path ، شکل‌های پیچیده‌تری رسم کنید. با اضافه کردن خطوط و منحنی‌ها به یک شیء Path ، یک شکل تعریف کنید، سپس با استفاده از drawPath() شکل را رسم کنید. همانند شکل‌های اولیه، مسیرها را می‌توان بسته به setStyle() دور تا دور، پر یا هر دو را رسم کرد.
  • با ایجاد اشیاء LinearGradient پر کردن گرادیان را تعریف کنید. برای استفاده از LinearGradient روی اشکال پر شده، تابع setShader() را فراخوانی کنید.
  • با استفاده از drawBitmap() تصاویر بیت‌مپ رسم کنید.

کد زیر ترکیبی از متن، خطوط و اشکال را رسم می‌کند:

کاتلین

private val data = mutableListOf<Item>() // A list of items that are displayed.

private var shadowBounds = RectF()       // Calculated in onSizeChanged.
private var pointerRadius: Float = 2f    // Obtained from styled attributes.
private var pointerX: Float = 0f         // Calculated in onSizeChanged.
private var pointerY: Float = 0f         // Calculated in onSizeChanged.
private var textX: Float = 0f            // Calculated in onSizeChanged.
private var textY: Float = 0f            // Calculated in onSizeChanged.
private var bounds = RectF()             // Calculated in onSizeChanged.
private var currentItem: Int = 0         // The index of the currently selected item.

override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)

    canvas.apply {
        // Draw the shadow.
        drawOval(shadowBounds, shadowPaint)

        // Draw the label text.
        drawText(data[currentItem].label, textX, textY, textPaint)

        // Draw the pie slices.
        data.forEach {item ->
            piePaint.shader = item.shader
            drawArc(
                bounds,
                360 - item.endAngle,
                item.endAngle - item.startAngle,
                true,
                piePaint
            )
        }

        // Draw the pointer.
        drawLine(textX, pointerY, pointerX, pointerY, textPaint)
        drawCircle(pointerX, pointerY, pointerRadius, textPaint)
    }
}

// Maintains the state for a data item.
private data class Item(
    var label: String,      
    var value: Float = 0f,

    @ColorInt
    var color: Int = 0,

    // Computed values.
    var startAngle: Float = 0f,
    var endAngle: Float = 0f,

    var shader: Shader
)

جاوا

private List<Item> data = new ArrayList<Item>();  // A list of items that are displayed.

private RectF shadowBounds;                       // Calculated in onSizeChanged.
private float pointerRadius;                      // Obtained from styled attributes.
private float pointerX;                           // Calculated in onSizeChanged.
private float pointerY;                           // Calculated in onSizeChanged.
private float textX;                              // Calculated in onSizeChanged.
private float textY;                              // Calculated in onSizeChanged.
private RectF bounds;                             // Calculated in onSizeChanged.
private int currentItem = 0;                      // The index of the currently selected item.

protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);

    // Draw the shadow.
    canvas.drawOval(
            shadowBounds,
            shadowPaint
    );

    // Draw the label text.
    canvas.drawText(data.get(currentItem).label, textX, textY, textPaint);

    // Draw the pie slices.
    for (int i = 0; i < data.size(); ++i) {
        Item it = data.get(i);
        piePaint.setShader(it.shader);
        canvas.drawArc(
                bounds,
                360 - it.endAngle,
                it.endAngle - it.startAngle,
                true, 
                piePaint
        );
    }

    // Draw the pointer.
    canvas.drawLine(textX, pointerY, pointerX, pointerY, textPaint);
    canvas.drawCircle(pointerX, pointerY, pointerRadius, textPaint);
}

// Maintains the state for a data item.
private class Item {
    public String label;
    public float value;
    @ColorInt
    public int color;

    // Computed values.
    public int startAngle;
    public int endAngle;

    public Shader shader;
}    

اعمال جلوه‌های گرافیکی

اندروید ۱۲ (سطح API 31) کلاس RenderEffect اضافه می‌کند که جلوه‌های گرافیکی رایج مانند محوشدگی‌ها، فیلترهای رنگی، جلوه‌های سایه‌زن اندروید و موارد دیگر را به اشیاء View و سلسله مراتب رندر اعمال می‌کند. می‌توانید جلوه‌ها را به صورت جلوه‌های زنجیره‌ای که شامل یک جلوه داخلی و خارجی هستند یا جلوه‌های ترکیبی ترکیب کنید. پشتیبانی از این ویژگی بسته به قدرت پردازش دستگاه متفاوت است.

همچنین می‌توانید با فراخوانی View.setRenderEffect(RenderEffect) جلوه‌هایی را به RenderNode زیرین برای یک View اعمال کنید.

برای پیاده‌سازی یک شیء RenderEffect ، مراحل زیر را انجام دهید:

view.setRenderEffect(RenderEffect.createBlurEffect(radiusX, radiusY, SHADER_TILE_MODE))

شما می‌توانید این نما را به صورت برنامه‌نویسی شده ایجاد کنید یا آن را از یک طرح‌بندی XML ایجاد کرده و با استفاده از View binding یا findViewById() آن را بازیابی کنید.