Connect4 — 01: לוח שמצויר ומגיב למגע


בונים מצב עובד אחד מתוך המסלול

מפת המסלול

בסוף הפרק נראה לוח בן 6 שורות ו־7 עמודות. נגיעה בלוח תציג את מספר העמודה שנבחרה. עדיין אין דיסקיות או תורות.

התחילו בפרויקט Empty Views Activity בשפת Java, חבילה com.example.connect4 ו־XML. לפני בניית הלוח, השלימו את הסבת הפרויקט ל־View Binding בסעיף הבא; היא מכינה את MainActivity ואת ה־layout שבהם נשתמש בפרק.

הסבת הפרויקט ל־View Binding

View Binding יוצר מחלקה עם הפניות ישירות ל־Views שבקובץ ה־XML, כך שלא צריך לחפש כל רכיב שוב באמצעות findViewById. כך הקוד קצר יותר, ושגיאות של מזהה או המרה בין טיפוסים מתגלות מוקדם יותר.

שלב 1 — הפעלת View Binding ב־Gradle

פתחו את build.gradle.kts (Module :app) והוסיפו את buildFeatures:

 android {
     namespace = "com.example.connect4"
     compileSdk {
         version = release(36)
     }

+    buildFeatures {
+        viewBinding = true
+    }
+
     defaultConfig {
         ...
     }
 }

לאחר העריכה סנכרנו את ה-Gradle alt.

שלב 2 — המרת MainActivity לשימוש ב־Binding

עִרְכוּ את MainActivity.java:

לפני

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    EdgeToEdge.enable(this);

    setContentView(R.layout.activity_main);
    ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
        Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
        v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
        return insets;
    });
}

אחרי

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    EdgeToEdge.enable(this);
    binding = ActivityMainBinding.inflate(getLayoutInflater());
    setContentView(binding.getRoot());
    ViewCompat.setOnApplyWindowInsetsListener(binding.main, (v, insets) -> {
        Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
        v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
        return insets;
    });
}

השדה binding מופיע באדום. בצעו right-click > ShowContextActions ובחרו Create Field: תפריט הפעולות של Android Studio עם האפשרות Create field 'binding' in 'MainActivity' זה יוסיף את השדה וגם יוסיף את ה-import שחסר לנו.

הסבר קצר: binding.getRoot() הוא ה־View הראשי של הפריסה. ב־binding.main ניגשים ל־View עם android:id="@+id/main"; לכן אין עוד צורך ב־findViewById. קוד ה־insets והריווח נשאר ללא שינוי.

ה־Activity אחראי לתגובה; ה־View אחראי לציור ולתרגום נקודת המגע לעמודה. הממשק ColumnListener מחבר ביניהם בלי שהלוח צריך להכיר את ה־Activity.

השינויים לפי סדר העבודה

התחילו במצב שעבד בסוף הפרק הקודם. קובץ חדש מוצג במלואו; בקובץ קיים מופיעים רק האזורים שמשתנים. בקטעי diff מסירים את שורות -, מוסיפים את שורות +, ומשאירים את שורות ההקשר. הסימנים עצמם אינם חלק מקוד Java או XML. אין למחוק קוד אחר שאינו מוצג.

activity_main.xml

app > res > layout > activity_main.xml

פתחו את התצוגה ב־Code. שומרים את ConstraintLayout של התבנית. נותנים מזהה להודעה, מסירים רק את העיגון התחתון שלה, ומוסיפים את הלוח מתחתיה. היחס 7:6 שומר על תאים ריבועיים. את BoardView ניצור מיד בשלב הבא; שגיאת התצוגה הזמנית תיעלם לאחר יצירת המחלקה.

 
     <TextView
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
-        android:text="Hello World!"
-        app:layout_constraintBottom_toBottomOf="parent"
+        android:id="@+id/status"
+        android:text="Tap a column"
         app:layout_constraintEnd_toEndOf="parent"
         app:layout_constraintStart_toStartOf="parent"
         app:layout_constraintTop_toTopOf="parent" />
 
+    <com.example.connect4.ui.BoardView
+        android:id="@+id/board"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        app:layout_constraintDimensionRatio="7:6"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/status" />
+
 </androidx.constraintlayout.widget.ConstraintLayout>

BoardView.java

app > kotlin+java > com.example.connect4 > ui > BoardView.java

צרו package בשם ui, ובו Java Class בשם BoardView. הבנאי עם AttributeSet מאפשר ל־Android ליצור את המחלקה מתוך XML. משתמשים באותו cellSize() לציור ולמגע: אם יש שטח נוסף ב־View, הוא אינו נחשב לחלק מהלוח.

מרכז תא הוא (column + 0.5) * size בציר האופקי ו־(row + 0.5) * size בציר האנכי. ב־Canvas הראשית בפינה השמאלית העליונה. ACTION_UP יוצר בחירה אחת בשחרור האצבע; performClick() משמר את חוזה הלחיצה של View.

package com.example.connect4.ui;

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;

/** Draws a six-by-seven board and reports column taps to its controller. */
public final class BoardView extends View {
    private static final int ROWS = 6;
    private static final int COLUMNS = 7;
    private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    private ColumnListener listener;

    /** Receives a zero-based column; the View does not apply game rules. */
    public interface ColumnListener { void onColumn(int column); }

    /** Called by Android when the custom View is inflated from XML. */
    public BoardView(Context context, AttributeSet attrs) {
        super(context, attrs);
        setContentDescription("Connect Four board. Tap a column to drop a disc.");
    }

    /** Connects the Activity's response to a board tap. */
    public void setColumnListener(ColumnListener listener) { this.listener = listener; }

    @Override protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        float size = cellSize();
        paint.setColor(0xff1565c0);
        canvas.drawRect(0, 0, size * COLUMNS, size * ROWS, paint);
        for (int row = 0; row < ROWS; row++) {
            for (int column = 0; column < COLUMNS; column++) {
                paint.setColor(0xffeeeeee);
                canvas.drawCircle((column + .5f) * size, (row + .5f) * size,
                        size * .38f, paint);
            }
        }
    }

    /** Uses the same geometry for drawing and hit testing. */
    private float cellSize() {
        return Math.min(getWidth() / (float) COLUMNS, getHeight() / (float) ROWS);
    }

    @Override public boolean onTouchEvent(MotionEvent event) {
        if (event.getAction() == MotionEvent.ACTION_UP) {
            float size = cellSize();
            float x = event.getX(), y = event.getY();
            if (size > 0 && x >= 0 && x < size * COLUMNS && y >= 0 && y < size * ROWS) {
                performClick();
                if (listener != null) listener.onColumn((int) (x / size));
            }
        }
        return true;
    }

    @Override public boolean performClick() { super.performClick(); return true; }
}

MainActivity.java

app > kotlin+java > com.example.connect4 > MainActivity.java

רשמו listener אחרי הגדרת ה־insets ב־onCreate. הקוד מקבל עמודה בטווח 0–6; רק הכיתוב לתלמיד מציג 1–7. ההמרה היא להצגה בלבד.

             Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
             v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
             return insets;
         });
+        binding.board.setColumnListener(column ->
+                binding.status.setText("Column " + (column + 1)));
     }
 }

מריצים ובודקים

  1. בצעו Build > Make Project, ואז Run.
  2. געו במרכז התא השמאלי: ההודעה צריכה להיות Column 1. בקצה הימני: Column 7.
  3. געו מעל הלוח או בשטח מחוצה לו: הבחירה אינה משתנה.
  4. סובבו את המכשיר: הלוח נשאר ביחס 7:6. עדיין לא שומרים בחירה בסיבוב.

המורה בדק גם View ברוחב 700 וגובה 300: הלוח המצויר מסתיים ב־x=350, ולכן x=349 הוא עמודה 6 ואילו x=350 כבר מחוץ ללוח.

בדיקת הבנה

מדוע חלוקה ב־getWidth()/7 בלבד עלולה לבחור עמודה שגויה כאשר ה־View גבוה או רחב מהלוח המצויר? חשבו את העמודה עבור x=125 כאשר גודל התא הוא 50.