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

امتحان کردن روش Compose
Jetpack Compose جعبه‌ابزار واسط کاربر توصیه‌شده برای Android است. با نحوه کار با چیدمان‌ها در Compose آشنا شوید.

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

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

لغو کردن onDraw()‎

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

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

ایجاد اشیای طراحی

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

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

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

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

کاتلین

@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 روش‌های زیادی برای مدیریت اندازه‌گیری دارد، اکثر آن‌ها نیازی به لغو شدن ندارند. اگر نمای شما به کنترل خاصی روی اندازه‌اش نیاز ندارد، فقط یک روش را لغو کنید: onSizeChanged().

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

وقتی اندازه‌ای به نمای شما اختصاص داده می‌شود، مدیر چیدمان فرض می‌کند که این اندازه شامل حاشیه داخلی نما است. هنگام محاسبه اندازه نمای خود، مقادیر لایه داخلی را مدیریت کنید. در اینجا گزیده‌ای از 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);
}

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

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

طراحی کردن

پس‌از تعریف کد اندازه‌گیری و ایجاد شیء، می‌توانید 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;
}    

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

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

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

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

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

می‌توانید نما را به‌صورت برنامه‌نویسی ایجاد کنید یا آن را از چیدمان XML گسترش دهید و بااستفاده از اتصال نما یا findViewById() بازیابی کنید.