Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Building a Minimal 2048 Clone on Android with a Custom Grid View

Tech 2

A 4×4 grid, a minimal score panel, and a custom view implement the full 2048 interaction loop: spawn, slide, merge, score, and game-over detection. Layout is defined in XML, while swipe handling and game rules live in a GridLayout-backed custom view.

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Score:"/>

        <TextView
            android:id="@+id/textScoreValue"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"/>
    </LinearLayout>

    <dev.sample.twentyfortyeight.BoardView
        android:id="@+id/boardView"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"/>
</LinearLayout>

TileView.java

package dev.sample.twentyfortyeight;

import android.content.Context;
import android.graphics.Color;
import android.view.Gravity;
import android.widget.FrameLayout;
import android.widget.TextView;

public class TileView extends FrameLayout {

    private TextView label;
    private int value;

    public TileView(Context context) {
        super(context);
        init();
    }

    public TileView(Context context, android.util.AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public TileView(Context context, android.util.AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        label = new TextView(getContext());
        label.setTextSize(30);
        label.setGravity(Gravity.CENTER);
        label.setBackgroundColor(0x33ffffff);

        LayoutParams lp = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
        lp.setMargins(10, 10, 0, 0);
        addView(label, lp);

        setValue(0);
    }

    public int getValue() {
        return value;
    }

    public void setValue(int v) {
        value = v;
        if (v <= 0) {
            label.setText("");
            label.setBackgroundColor(0x33ffffff);
        } else {
            label.setText(String.valueOf(v));
            label.setBackgroundColor(colorFor(v));
        }
    }

    public boolean sameAs(TileView other) {
        return other != null && value == other.getValue();
    }

    private int colorFor(int v) {
        switch (v) {
            case 2: return 0xffeee4da;
            case 4: return 0xffede0c8;
            case 8: return 0xfff2b179;
            case 16: return 0xfff59563;
            case 32: return 0xfff67c5f;
            case 64: return 0xfff65e3b;
            case 128: return 0xffedcf72;
            case 256: return 0xffedcc61;
            case 512: return 0xffedc850;
            case 1024: return 0xffedc53f;
            case 2048: return 0xffedc22e;
            default:
                return Color.WHITE;
        }
    }
}

BoardView.java

package dev.sample.twentyfortyeight;

import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.graphics.Point;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.widget.GridLayout;

import java.util.ArrayList;
import java.util.List;

public class BoardView extends GridLayout {

    public interface ScoreSink {
        void onReset();
        void onGain(int points);
    }

    private ScoreSink scoreSink;

    public void setScoreSink(ScoreSink sink) {
        this.scoreSink = sink;
    }

    private static final int SIZE = 4;

    private TileView[][] tiles = new TileView[SIZE][SIZE];
    private final List<Point> free = new ArrayList<>();

    private float downX, downY;

    public BoardView(Context context) {
        super(context);
        setup();
    }

    public BoardView(Context context, AttributeSet attrs) {
        super(context, attrs);
        setup();
    }

    public BoardView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        setup();
    }

    private void setup() {
        setColumnCount(SIZE);
        setBackgroundColor(0xffbbada0);

        setOnTouchListener(new OnTouchListener() {
            @Override
            public boolean onTouch(View v, MotionEvent e) {
                switch (e.getAction()) {
                    case MotionEvent.ACTION_DOWN:
                        downX = e.getX();
                        downY = e.getY();
                        return true;
                    case MotionEvent.ACTION_UP:
                        float dx = e.getX() - downX;
                        float dy = e.getY() - downY;
                        if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return true;
                        if (Math.abs(dx) > Math.abs(dy)) {
                            if (dx < 0) slide(Direction.LEFT);
                            else slide(Direction.RIGHT);
                        } else {
                            if (dy < 0) slide(Direction.UP);
                            else slide(Direction.DOWN);
                        }
                        return true;
                }
                return false;
            }
        });
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        int cell = (Math.min(w, h) - 10) / SIZE;
        buildCells(cell);
        start();
    }

    private void buildCells(int size) {
        removeAllViews();
        for (int r = 0; r < SIZE; r++) {
            for (int c = 0; c < SIZE; c++) {
                TileView t = new TileView(getContext());
                addView(t, size, size);
                tiles[r][c] = t;
            }
        }
    }

    private void start() {
        if (scoreSink != null) scoreSink.onReset();
        for (int r = 0; r < SIZE; r++) {
            for (int c = 0; c < SIZE; c++) {
                tiles[r][c].setValue(0);
            }
        }
        spawn();
        spawn();
    }

    private void spawn() {
        free.clear();
        for (int r = 0; r < SIZE; r++) {
            for (int c = 0; c < SIZE; c++) {
                if (tiles[r][c].getValue() == 0) free.add(new Point(r, c));
            }
        }
        if (free.isEmpty()) return;
        Point p = free.remove((int) (Math.random() * free.size()));
        tiles[p.x][p.y].setValue(Math.random() < 0.9 ? 2 : 4);
    }

    private enum Direction { LEFT, RIGHT, UP, DOWN }

    private void slide(Direction dir) {
        boolean changed = false;
        int gainedTotal = 0;

        for (int i = 0; i < SIZE; i++) {
            int[] line = readLine(i, dir);
            MergeResult res = merge(line);
            writeLine(i, dir, res.values);
            if (!equals(line, res.values)) changed = true;
            gainedTotal += res.gained;
        }

        if (changed) {
            if (scoreSink != null && gainedTotal > 0) scoreSink.onGain(gainedTotal);
            spawn();
            if (isGameOver()) showGameOver();
        }
    }

    private static class MergeResult {
        int[] values;
        int gained;
    }

    private MergeResult merge(int[] raw) {
        // compact non-zeros
        int[] compact = new int[SIZE];
        int idx = 0;
        for (int v : raw) if (v != 0) compact[idx++] = v;

        int[] out = new int[SIZE];
        int write = 0;
        int gained = 0;
        for (int read = 0; read < idx; ) {
            if (read + 1 < idx && compact[read] == compact[read + 1]) {
                int merged = compact[read] * 2;
                out[write++] = merged;
                gained += merged;
                read += 2;
            } else {
                out[write++] = compact[read++];
            }
        }
        MergeResult r = new MergeResult();
        r.values = out;
        r.gained = gained;
        return r;
    }

    private boolean equals(int[] a, int[] b) {
        for (int i = 0; i < SIZE; i++) if (a[i] != b[i]) return false;
        return true;
    }

    private int[] readLine(int index, Direction d) {
        int[] out = new int[SIZE];
        switch (d) {
            case LEFT:
                for (int c = 0; c < SIZE; c++) out[c] = tiles[index][c].getValue();
                break;
            case RIGHT:
                for (int c = 0; c < SIZE; c++) out[c] = tiles[index][SIZE - 1 - c].getValue();
                break;
            case UP:
                for (int r = 0; r < SIZE; r++) out[r] = tiles[r][index].getValue();
                break;
            case DOWN:
                for (int r = 0; r < SIZE; r++) out[r] = tiles[SIZE - 1 - r][index].getValue();
                break;
        }
        return out;
    }

    private void writeLine(int index, Direction d, int[] vals) {
        switch (d) {
            case LEFT:
                for (int c = 0; c < SIZE; c++) tiles[index][c].setValue(vals[c]);
                break;
            case RIGHT:
                for (int c = 0; c < SIZE; c++) tiles[index][SIZE - 1 - c].setValue(vals[c]);
                break;
            case UP:
                for (int r = 0; r < SIZE; r++) tiles[r][index].setValue(vals[r]);
                break;
            case DOWN:
                for (int r = 0; r < SIZE; r++) tiles[SIZE - 1 - r][index].setValue(vals[r]);
                break;
        }
    }

    private boolean isGameOver() {
        // any empty?
        for (int r = 0; r < SIZE; r++)
            for (int c = 0; c < SIZE; c++)
                if (tiles[r][c].getValue() == 0) return false;

        // any mergeable neighbors?
        for (int r = 0; r < SIZE; r++) {
            for (int c = 0; c < SIZE; c++) {
                int v = tiles[r][c].getValue();
                if (r + 1 < SIZE && tiles[r + 1][c].getValue() == v) return false;
                if (c + 1 < SIZE && tiles[r][c + 1].getValue() == v) return false;
            }
        }
        return true;
    }

    private void showGameOver() {
        new AlertDialog.Builder(getContext())
            .setTitle("Game Over")
            .setMessage("No more moves.")
            .setPositiveButton("Restart", new DialogInterface.OnClickListener() {
                @Override public void onClick(DialogInterface dialog, int which) { start(); }
            })
            .show();
    }
}

MainActivity.java

package dev.sample.twentyfortyeight;

import android.app.Activity;
import android.os.Bundle;
import android.widget.TextView;

public class MainActivity extends Activity {

    private TextView scoreText;
    private int score;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        scoreText = findViewById(R.id.textScoreValue);
        BoardView board = findViewById(R.id.boardView);

        board.setScoreSink(new BoardView.ScoreSink() {
            @Override
            public void onReset() {
                score = 0;
                scoreText.setText(String.valueOf(score));
            }

            @Override
            public void onGain(int points) {
                score += points;
                scoreText.setText(String.valueOf(score));
            }
        });
    }
}

Implementation details

  • Grid and UI

    • GridLayout renders a 4×4 board. Each tile is a TileView (FrameLayout with a centered TextView). Spacing is created with inner margins in TileView.
    • Score update are decoupled via BoardView.ScoreSink; MainActivity supplies the implementation too reset and add scores.
  • Input and movement

    • A simple OnTouchListener measures the drag delta at finger release. Horizontal magnitude greater than vertical triggers left/right; otherwise up/down.
    • Movement and merging use a direction-agnostic ppieline: read a line, compact non-zeros, merge adjacent equals once, then write the result back in directional order.
  • Spawn and end condisions

    • A random empty cell receives 2 (90%) or 4 (10%) after every valid move.
    • The game ends if there are no empty cells and no adjacent horizontal/vertcial equal pairs; a dialog offers to restart.
Tags: Android

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.