זהו השלב הראשון בפרויקט. אנחנו מתחילים מהפרויקט הראשוני שכבר נוצר ב-Android Studio, ומסיימים עם יישום שרץ ומציג לוח סטטי: מטרה אדומה במרכז ושלושה עיגולים ירוקים. עדיין אין משחק, גרירה או שעון פעיל - אותם נוסיף בשלבים הבאים.
אם אתם יוצרים את הפרויקט בעצמכם, בחרו בתבנית Empty Views Activity והשאירו
את שם הפעילות MainActivity. משנים את הקבצים שהתבנית יצרה; אין צורך להוסיף
Activity נוסף.
מה נלמד
- כיצד XML ו-Java מתחברים בעזרת View Binding.
- כיצד יוצרים
Viewשמצייר בעצמו בעזרתCanvas. - כיצד מחלקת
Circleמרכזת נתונים ופעולות משותפים. - כיצד
Target extends Circleמוסיפה התנהגות בעזרת ירושה ו-@Override. - ההבדל בין חפיפה של שני עיגולים לבין הכלה מלאה של עיגול בתוך המטרה.
1. מוודאים ש-View Binding פעיל
פתחו את app/build.gradle.kts והוסיפו בתוך android:
buildFeatures {
viewBinding = true
}
זו הפעולה הטכנית הראשונה בשיעור. לאחר השינוי בצעו Gradle Sync.
ההגדרה גורמת ל-Android ליצור מחלקת binding לכל קובץ layout. מהשם
activity_main.xml נוצרת המחלקה ActivityMainBinding, ובה שדה עם טיפוס מתאים לכל
View שיש לו android:id.
ב-MainActivity כבר מנפחים את המסך ומציגים אותו כך:
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
לכן נוכל לגשת לכפתור בשם binding.startButton, בלי findViewById ובלי המרה ידנית
לטיפוס הנכון.
2. בונים את המסך ב-XML
רוצים ללמוד כיצד לבנות את אותו מסך בגרירה ובחיבור constraints בעזרת העכבר? עברו אל CollectCircles 1א - בניית המסך בעזרת Layout Editor. אפשר לבצע את שיעור הרשות עכשיו או לחזור אליו מאוחר יותר.
החליפו את התוכן של activity_main.xml. המסך מכיל כותרת, שתי תוויות זמן, כפתור
ולוח משחק שתופס את יתרת הגובה:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
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:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/screen_background"
android:paddingHorizontal="20dp"
tools:context=".MainActivity">
<TextView
android:id="@+id/titleText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:gravity="center"
android:text="@string/game_title"
android:textColor="@color/title_purple"
android:textSize="26sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/elapsedTimeText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:gravity="start"
android:text="@string/elapsed_time_initial"
android:textColor="@color/time_brown"
android:textSize="16sp"
app:layout_constraintEnd_toStartOf="@id/bestTimeText"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/titleText" />
<TextView
android:id="@+id/bestTimeText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="end"
android:text="@string/best_time_initial"
android:textColor="@color/title_purple"
android:textSize="16sp"
app:layout_constraintBaseline_toBaselineOf="@id/elapsedTimeText"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/elapsedTimeText" />
<com.google.android.material.button.MaterialButton
android:id="@+id/startButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/start"
android:textSize="18sp"
app:cornerRadius="14dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/elapsedTimeText" />
<com.example.collectcircles.GameBoardView
android:id="@+id/gameBoard"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="20dp"
android:minHeight="320dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/startButton"
tools:background="@color/board_background" />
</androidx.constraintlayout.widget.ConstraintLayout>
0dp ב-ConstraintLayout אינו אומר “גודל אפס”. הוא אומר שהרכיב יקבל את הגודל
שנקבע על-ידי האילוצים שלו. לכן gameBoard נמתח בין הכפתור לבין תחתית המסך.
הוסיפו ל-strings.xml:
<string name="app_name">Collect Circles</string>
<string name="game_title">Speed Collector</string>
<string name="elapsed_time_initial">Time: 0.000 s</string>
<string name="best_time_initial">Best: —</string>
<string name="start">Start</string>
והוסיפו ל-colors.xml:
<color name="screen_background">#FFFDFB</color>
<color name="board_background">#F2EDFF</color>
<color name="title_purple">#6A3DA3</color>
<color name="time_brown">#795548</color>
<color name="target_red">#E53935</color>
<color name="target_cross">#FFFFFFFF</color>
<color name="circle_green">#43A047</color>
3. יוצרים את Circle
צרו את Circle.java. כל עיגול שומר את מרכזו, את הרדיוס ואת הצבע שלו:
package com.example.collectcircles;
import android.graphics.Canvas;
import android.graphics.Paint;
public class Circle {
private float centerX;
private float centerY;
private final float radius;
private final int color;
public Circle(float centerX, float centerY, float radius, int color) {
this.centerX = centerX;
this.centerY = centerY;
this.radius = radius;
this.color = color;
}
public float getCenterX() {
return centerX;
}
public float getCenterY() {
return centerY;
}
public float getRadius() {
return radius;
}
public void setCenter(float centerX, float centerY) {
this.centerX = centerX;
this.centerY = centerY;
}
public double distanceTo(Circle other) {
return distanceTo(other.centerX, other.centerY);
}
public double distanceTo(float pointX, float pointY) {
float dx = centerX - pointX;
float dy = centerY - pointY;
return Math.sqrt(dx * dx + dy * dy);
}
public boolean contains(float pointX, float pointY) {
return distanceTo(pointX, pointY) <= radius;
}
public boolean overlaps(Circle other) {
return distanceTo(other) < radius + other.radius;
}
public void draw(Canvas canvas, Paint paint) {
paint.setStyle(Paint.Style.FILL);
paint.setColor(color);
canvas.drawCircle(centerX, centerY, radius, paint);
}
}
המרחק בין שתי נקודות מחושב לפי משפט פיתגורס:
dx = x1 - x2
dy = y1 - y2
distance = sqrt(dx * dx + dy * dy)
אותו מרחק מאפשר לענות על שתי שאלות:
- נקודה נמצאת בעיגול כאשר המרחק שלה מן המרכז קטן או שווה לרדיוס.
- שני עיגולים חופפים כאשר המרחק בין המרכזים קטן מסכום הרדיוסים.
distance < r1 + r2
כאשר המרחק שווה בדיוק לסכום הרדיוסים, העיגולים רק נוגעים זה בזה ולכן
overlaps מחזירה false.
4. יוצרים Target extends Circle
המטרה היא עיגול, אבל היא גם יודעת לצייר צלב ולבדוק הכלה מלאה. במקום לשכפל את כל
הקוד של Circle, נשתמש בירושה:
package com.example.collectcircles;
import android.graphics.Canvas;
import android.graphics.Paint;
public class Target extends Circle {
private final int crossColor;
private final float crossStrokeWidth;
public Target(float centerX, float centerY, float radius, int color,
int crossColor, float crossStrokeWidth) {
super(centerX, centerY, radius, color);
this.crossColor = crossColor;
this.crossStrokeWidth = crossStrokeWidth;
}
public boolean fullyContains(Circle circle) {
return distanceTo(circle) + circle.getRadius() <= getRadius();
}
@Override
public void draw(Canvas canvas, Paint paint) {
super.draw(canvas, paint);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(crossStrokeWidth);
paint.setColor(crossColor);
canvas.drawLine(
getCenterX(), getCenterY() - getRadius(),
getCenterX(), getCenterY() + getRadius(), paint
);
canvas.drawLine(
getCenterX() - getRadius(), getCenterY(),
getCenterX() + getRadius(), getCenterY(), paint
);
}
}
super(...) מפעיל את הבנאי של Circle. בתוך draw, הקריאה
super.draw(...) מציירת תחילה את גוף העיגול האדום; לאחר מכן מציירים שני קווים
לבנים. @Override מבקש מהקומפיילר לוודא שאנחנו באמת דורסים פעולה שקיימת במחלקת
האב.
חפיפה והכלה מלאה אינן אותה בדיקה. לחפיפה מספיק
distance < r1 + r2. כדי שעיגול קטן יהיה כולו בתוך המטרה, גם הקצה הרחוק שלו
חייב להישאר בתוך רדיוס המטרה:
distance + smallRadius <= targetRadius.
5. יוצרים לוח שמצייר את עצמו
צרו את GameBoardView.java. ב-Android אין צורך לחקות את ההפרדה של MAUI בין
GraphicsView לבין IDrawable: מחלקה שיורשת מ-View יכולה להיות משטח הציור
בעצמה.
החלקים החשובים במחלקה הם:
public class GameBoardView extends View {
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
private Target target;
private Circle[] previewCircles = new Circle[0];
public GameBoardView(Context context) {
this(context, null);
}
public GameBoardView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public GameBoardView(Context context, AttributeSet attrs,
int defStyleAttr) {
super(context, attrs, defStyleAttr);
setBackgroundColor(color(R.color.board_background));
}
@Override
protected void onSizeChanged(int width, int height,
int oldWidth, int oldHeight) {
float centerX = width / 2f;
float centerY = height / 2f;
float targetRadius = Math.min(
dp(64f),
Math.min(width, height) / 5f
);
float circleRadius = targetRadius / 2f;
target = new Target(
centerX, centerY, targetRadius,
color(R.color.target_red),
color(R.color.target_cross),
dp(2f)
);
previewCircles = new Circle[] {
new Circle(circleRadius * 1.5f, circleRadius * 1.5f,
circleRadius, color(R.color.circle_green)),
new Circle(width - circleRadius * 1.5f, circleRadius * 2.2f,
circleRadius, color(R.color.circle_green)),
new Circle(circleRadius * 1.8f, height - circleRadius * 1.7f,
circleRadius, color(R.color.circle_green))
};
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (target == null) {
return;
}
target.draw(canvas, paint);
for (Circle circle : previewCircles) {
circle.draw(canvas, paint);
}
}
private float dp(float value) {
return value * getResources().getDisplayMetrics().density;
}
private int color(int colorResource) {
return ContextCompat.getColor(getContext(), colorResource);
}
}
הוסיפו בראש הקובץ את ה-imports של Context, Canvas, Paint, AttributeSet,
View ו-ContextCompat. ההערות @Nullable ו-@ColorInt רק עוזרות
ל-Android Studio לבדוק את הקוד.
שלושת הבנאים מאפשרים ל-Android ליצור את ה-View גם מתוך XML.
הפעולה dp(...) ממירה יחידות צפיפות לפיקסלים, ו-color(...) קוראת צבע מן
המשאבים במקום לקודד אותו ישירות ב-Java.
onSizeChanged מתאימה את האובייקטים לגודל האמיתי של הלוח. onDraw מקבלת
Canvas, שהוא משטח הציור של Android. האובייקט Paint קובע צבע וסגנון ציור,
ו-ANTI_ALIAS_FLAG מחליק את שולי העיגולים.
בשלב הזה המיקומים קבועים בכוונה. כך אפשר לבדוק את הציור לפני שמוסיפים אקראיות ומצב משחק.
6. משתמשים ב-binding מתוך הפעילות
בסוף onCreate הוסיפו:
// View Binding gives us a typed reference generated from activity_main.xml.
// The button becomes active in the next teaching step, when a playable game exists.
binding.startButton.setEnabled(false);
הכפתור מוצג, אבל מושבת. זהו מצב ביניים אמיתי וברור: בנינו את המסך ואת עולם הציור, ועדיין לא הבטחנו למשתמש שמשחק מלא קיים.
7. בדיקה
הריצו:
.\gradlew.bat testDebugUnitTest assembleDebug
אם JAVA_HOME אינו מוגדר ב-PowerShell:
$env:JAVA_HOME = 'C:\Program Files\Android\Android Studio\jbr'
.\gradlew.bat testDebugUnitTest assembleDebug
בדקו שמתקבלים:
- כותרת, זמן התחלתי, שיא התחלתי וכפתור Start מושבת.
- לוח לבנדר גדול.
- מטרה אדומה במרכז עם קו אנכי וקו אופקי.
- שלושה עיגולים ירוקים.
- בדיקות היחידה מאשרות את ההבדל בין חפיפה, נגיעה והכלה מלאה.
יש לנו כעת בסיס גרפי שרץ בפני עצמו. בגרסה הבאה נחליף את תצוגת הדוגמה במשחק אמיתי עם חמישה עיגולים אקראיים, מגע וגרירה.