Memakai Database SQLite Di Android

Pada artikel Mencari Lokasi Dengan GPS Di Android, saya membuat aplikasi yang mencari koordinat lokasi perangkat saat ini melalui GPS. Akan lebih baik bila aplikasi latihan tersebut memiliki fasilitas untuk menyimpan koordinat yang telah saya kunjungi. Ini akan mempermudah saya mencari kembali rumah seseorang atau tempat tertentu yang sudah pernah saya kunjungi bila saya lupa (atau tersesat) di kemudian hari. Salah satu solusi yang bisa saya pakai adalah dengan menyimpan data lokasi ke dalam database.

Pada Android, setiap aplikasi memiliki akses ke database SQLite untuk menyimpanan data secara internal. Database yang dibuat oleh sebuah aplikasi hanya bisa dipakai (dilihat dan diubah) oleh aplikasi tersebut saja. Aplikasi lain akan memakai database berbeda yang khusus untuk aplikasi tersebut. Dengan demikian, database SQLite bawaan ini tidak ditujukan untuk pertukaran data seperti halnya pada database client server umumnya. Ia hanya ditujukan sebagai penampungan data permanen bagi masing-masing aplikasi; menulis data ke database dapat dianggap sebagai solusi penampungan data yang lebih terstruktur dibandingkan dengan menulis data ke file.

Untuk memakai database, langkah pertama yang saya lakukan adalah membuat sebuah turunan dari SQLiteOpenHelper seperti pada kode program berikut ini:

public class DbHelper extends SQLiteOpenHelper {

    public static final int DATABASE_VERSION = 1;

    public DbHelper(Context context) {
        super(context, "MyDb", null, DATABASE_VERSION);
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
        db.execSQL("CREATE TABLE lokasi (" +
            " _id INTEGER PRIMARY KEY, " +
            "nama TEXT NOT NULL, " +
            "longitude REAL, " +
            "latitude REAL, " + 
            "altitude REAL, " +
            "akurasi REAL, " +
            "azimuth REAL)"
        );
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
    }

}

Method onCreate() pada SQLiteOpenHelper akan dikerjakan pada saat database pertama kali dibuat. Saya bisa membuat seluruh tabel yang dibutuhkan serta mengisi nilai default pada method ini. Selain itu, SQLiteOpenHelper juga memiliki informasi versi database. Bila suatu saat saya melakukan perubahan pada struktur tabel di database, saya bisa meningkatkan angka milik DATABASE_VERSION. Dengan demikian, bila database versi sebelumnya sudah ada di perangkat pengguna, maka method onUpgrade() akan dikerjakan (bukan lagi onCreate()!). Saya bisa mengerjakan SQL ALTER TABLE di method ini untuk mengubah struktur tabel lama menjadi sesuai dengan struktur yang baru.

Sekarang, saya akan membuat sebuah menu baru di menu_main.xml seperti berikut ini:

<menu ...>

  ...

  <item android:id="@+id/menu_simpan" app:showAsAction="ifRoom"
     android:title="Simpan" />

</menu>

Setelah itu, saya menambahkan aksi yang akan dikerjakan saat menu di-klik seperti berikut ini:

...
// Simpan ke database
menu.findItem(R.id.menu_simpan).setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() {
    @Override
    public boolean onMenuItemClick(MenuItem item) {
        if (lokasiTerakhir != null) {
            final Lokasi lokasi = new Lokasi(lokasiTerakhir);
            // Input nama lokasi
            AlertDialog.Builder builder = new AlertDialog.Builder(LokasiActivity.this);
            final EditText txtNamaLokasi = new EditText(LokasiActivity.this);
            builder.setView(txtNamaLokasi).setTitle("Nama Lokasi").setPositiveButton("Simpan", new DialogInterface.OnClickListener() {
                @Override
                public void onClick(DialogInterface dialog, int which) {
                    lokasi.setNama(txtNamaLokasi.getText().toString());
                    simpan(lokasi);
                }
            }).setNegativeButton("Batal", new DialogInterface.OnClickListener() {
                @Override
                public void onClick(DialogInterface dialog, int which) {
                    dialog.cancel();
                }
            }).create().show();
        }
        return true;
    };
});
...

Kode program di atas membuat sebuah object Lokasi baru berdasarkan yang sudah ada dengan menggunakan copy constructor yang saya tambahkan. Setelah itu, ia akan menampilkan sebuah dialog untuk mengisi nama lokasi seperti pada gambar berikut ini:

Tampilan Dialog

Tampilan Dialog

Bila pengguna memilih Simpan, maka method simpan() akan dikerjakan. Isi method tersebut terlihat seperti pada contoh berikut ini:

public class LokasiActivity extends Activity implements LocationListener, SensorEventListener {

    ...

    public void simpan(final Lokasi lokasi) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                DbHelper dbHelper = new DbHelper(LokasiActivity.this);
                SQLiteDatabase db = dbHelper.getWritableDatabase();
                ContentValues values = new ContentValues();
                values.put("nama", lokasi.getNama());
                values.put("longitude", lokasi.getLongitude());
                values.put("latitude", lokasi.getLatitude());
                values.put("altitude", lokasi.getAltitude());
                values.put("akurasi", lokasi.getAkurasi());
                values.put("azimuth", lokasi.getAzimuth());
                db.insert("lokasi", null, values);
                LokasiActivity.this.runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        Toast.makeText(LokasiActivity.this, "Lokasi berhasil disimpan!", Toast.LENGTH_SHORT).show();
                    }
                });
            }
        }).start();
    }

}

Pada kode program di atas, saya mengerjakan operasi yang mengakses database pada sebuah thread terpisah diluar main thread. Setelah memperoleh sebuah SQLiteDatabase melalui getWritableDatabase(), saya bisa mengerjakan method seperti execSQL() dan insert() untuk menambah record baru di tabel. Pada contoh di atas, saya menggunakan insert() yang lebih aman dari SQL injection karena ia akan membuat prepared statement yang menggunakan parameter.

Untuk menampilkan seluruh record yang ada di database, saya akan membuat sebuah activity baru. Sebagai contoh, saya menamakannya RiwayatActivity dan memakai kode program seperti berikut ini:

public class RiwayatActivity extends Activity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        DbHelper dbHelper = new DbHelper(this);
        SQLiteDatabase db = dbHelper.getReadableDatabase();
        Cursor cursor = db.rawQuery("SELECT * FROM lokasi", null);
        ListView listView = new ListView(this);
        listView.setAdapter(new RiwayatCursorAdapter(this, cursor));
        setContentView(listView);
    }

}

Kode program di atas memanggil rawQuery() untuk memberikan perintah SQL SELECT untuk mengambil data. Hasil kembaliannya berupa Cursor yang memungkinkan saya melakukan navigasi record per record. Selain menggunakan rawQuery(), saya juga bisa menggunakan query() yang tidak memakai SQL secara langsung. Untuk menampilkan Cursor ke dalam ListView, saya membuat sebuah turunan CursorAdapter seperti berikut ini:

public class RiwayatCursorAdapter extends CursorAdapter {

    public RiwayatCursorAdapter(Context context, Cursor c) {
        super(context, c, false);
    }

    @Override
    public View newView(Context context, Cursor cursor, ViewGroup parent) {
        TextView textView = new TextView(context);
        textView.setTextSize(18);
        textView.setPadding(16, 16, 16, 16);
        return textView;
    }

    @Override
    public void bindView(View view, Context context, Cursor cursor) {
        String nama = cursor.getString(cursor.getColumnIndexOrThrow("nama"));
        ((TextView) view).setText(nama);
    }

}

Saya kemudian menambahkan sebuah menu baru pada menu_main.xml seperti berikut ini:

<menu...>

  ...

  <item android:id="@+id/menu_riwayat" app:showAsAction="never"
    android:title="Riwayat" />

</menu>

Bila menu tersebut dipilih, kode program berikut ini akan dikerjakan:

...
// Tampilkan riwayat
menu.findItem(R.id.menu_riwayat).setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() {
    @Override
    public boolean onMenuItemClick(MenuItem item) {
        startActivity(new Intent(LokasiActivity.this, RiwayatActivity.class));
        return true;
    }
});
...

Kode program di atas akan memanggil RiwayatActivity sehingga daftar lokasi yang tersimpan di database akan ditampilkan. Nilai yang ditampilkan pada ListView hanya nama lokasi. Bila pengguna memilih salah satu lokasi di ListView tersebut, saya ingin LokasiActivity kembali dipanggil untuk menampilkan informasi khusus untuk lokasi tersebut. Dengan demikian, bila LokasiActivity dipanggil tanpa parameter, maka ia akan menampilkan lokasi dari GPS; sebaliknya, bila ia dipanggil dengan parameter berupa sebuah Lokasi, maka ia akan menampilkan informasi untuk Lokasi tersebut. Oleh sebab itu, saya mengubah LokasiActivity menjadi seperti berikut ini:

public class LokasiActivity extends Activity implements LocationListener, SensorEventListener {

  ...

  @Override
  protected void onStart() {
    super.onStart();
    Intent intent = getIntent();
    if (intent.hasExtra("lokasi")) {
      lokasiTerakhir = intent.getParcelableExtra("lokasi");
      refreshTampilan();
    } else {
       ...
    }
  }

  public void refreshTampilan() {
    TextView output = (TextView) findViewById(R.id.output);
    String strOutput = "DMS: " + lokasiTerakhir.koordinatDMS() + "n" +
      "DD: " + lokasiTerakhir.koordinatDD() + "n" +
      "Altitude: " + lokasiTerakhir.getAltitude() + " mn" +
      "Akurasi: " + lokasiTerakhir.getAkurasi() + " mn" +
      "Azimuth: " + lokasiTerakhir.getAzimuth() + " derajatn";
    output.setText(strOutput);

    // Share lokasi DD
    if ((lokasiTerakhir != null) && (shareActionProvider != null)) {
      Intent lokasiDDIntent = new Intent();
      lokasiDDIntent.setAction(Intent.ACTION_SEND);
      lokasiDDIntent.putExtra(Intent.EXTRA_TEXT, lokasiTerakhir.koordinatDD());
      lokasiDDIntent.setType("text/plain");
      shareActionProvider.setShareIntent(lokasiDDIntent);
    }
  }

  @Override
  public void onLocationChanged(Location location) {
    // Hitung rotasi
    ...
    lokasiTerakhir.setAzimuth(azimuth);
    refreshTampilan();
  }

  @Override
  protected void onPause() {
    super.onPause();
    if (locationManager != null) {
      locationManager.removeUpdates(this);
    }
    if (sensorManager != null) {
      sensorManager.unregisterListener(this);
    }
  }

  ...

}

Pada kode program di atas, saya memeriksa apakah ada sebuah parameter "lokasi" yang berisi sebuah Parceable yang mewakili object Lokasi. Karena sekarang Lokasi perlu dilewatkan antar activity, saya perlu mengimplementasikan Parceable pada Lokasi seperti pada contoh berikut ini:

public class Lokasi implements Parcelable {

  ...

  @Override
  public int describeContents() {
    return 0;
  }

  @Override
  public void writeToParcel(Parcel dest, int flags) {
    dest.writeString(nama);
    dest.writeDouble(longitude);
    dest.writeDouble(latitude);
    dest.writeDouble(altitude);
    dest.writeFloat(akurasi);
    dest.writeFloat(azimuth);
  }

  public static final Creator<Lokasi> CREATOR = new Creator<Lokasi>() {
    @Override
    public Lokasi createFromParcel(Parcel source) {
      return new Lokasi(source);
    }

    @Override
    public Lokasi[] newArray(int size) {
      return new Lokasi[size];
    }
  };

  private Lokasi(Parcel source) {
    nama = source.readString();
    longitude = source.readDouble();
    latitude = source.readDouble();
    altitude = source.readDouble();
    akurasi = source.readFloat();
    azimuth = source.readFloat();
  }

}

Komunikasi antar activity tidak bisa sekedar melewatkan referensi object seperti di pemograman Java pada umumnya. Hal ini karena activity yang berisi object yang dilewatkan bisa saja sudah dimusnahkan Android pada saat activity baru membutuhkan memori lebih. Dengan demikian, bila variabel dilewatkan melalui referensi, maka referensi tersebut akan merujuk pada memori yang sudah dimusnahkan. Salah satu solusi yang bisa ditempuh adalah dengan melewatkan Parceable sehingga object bisa di-‘buat ulang’ dengan mudah. Pada sebuah Parcelable, saya wajib menyediakan sebuah CREATOR yang memberi tahu bagaimana cara menghasilkan object ini. Urutan disini harus sama dengan urutan pada writeToParcel().

Sekarang, saya menambahkan listener yang akan dikerjakan bila salah satu item di RiwayatActivity dipilih oleh pengguna:

public class RiwayatActivity extends Activity implements AdapterView.OnItemClickListener {

  private RiwayatCursorAdapter riwayatCursorAdapter;

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    ...
    listView.setOnItemClickListener(this);
  }

  @Override
  public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
    Cursor cursor = (Cursor) riwayatCursorAdapter.getItem(position);
    Lokasi lokasi = new Lokasi(
      cursor.getString(cursor.getColumnIndexOrThrow("nama")),
      cursor.getDouble(cursor.getColumnIndexOrThrow("longitude")),
      cursor.getDouble(cursor.getColumnIndexOrThrow("latitude")),
      cursor.getDouble(cursor.getColumnIndexOrThrow("altitude")),
      cursor.getFloat(cursor.getColumnIndexOrThrow("akurasi")),
      cursor.getFloat(cursor.getColumnIndexOrThrow("azimuth"))
    );
    Intent intent = new Intent(this, LokasiActivity.class);
    intent.putExtra("lokasi", lokasi);
    startActivity(intent);
  }
}

Pada kode program di atas, saya mendapatkan Cursor untuk baris yang dipilih dengan memanggil getItem() milik CursorAdapter. Setelah itu, saya membaca nilai setiap kolom pada record tersebut guna membuat sebuah Lokasi baru. Untuk melewatkan object sebagai sebagai parameter bagi activity yang dipanggil, saya menggunakan putExtra() dari Intent.

Sampai disini, saya sudah bisa melihat informasi posisi untuk setiap record yang tersimpan di database SQLite. Sebagai latihan terakhir, saya akan menambahkan fasilitas untuk menghapus record. Langkah pertama yang saya lakukan adalah membuat sebuah context menu baru yang akan ditampilkan bila pengguna menahan jari agak lama pada salah satu baris di ListView yang berisi lokasi. Sebagai contoh, saya memberinya nama menu_context_riwayat.xml dan mendeklarasikan sebuah item menu seperti berikut ini:

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">

    <item android:id="@+id/hapus" android:title="Hapus" />

</menu>

Setelah itu, saya mengubah kode program untuk RiwayatActivity menjadi seperti berikut ini:

public class RiwayatActivity extends Activity implements AdapterView.OnItemClickListener {

  private RiwayatCursorAdapter riwayatCursorAdapter;
  SQLiteDatabase db;

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    ...
    db = dbHelper.getReadableDatabase();
    Cursor cursor = db.rawQuery("SELECT * FROM lokasi", null);
    ...
    listView.setOnItemClickListener(this);
    registerForContextMenu(listView);
  }

  ...

  @Override
  public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) {
    getMenuInflater().inflate(R.menu.menu_context_riwayat, menu);
  }

  @Override
  public boolean onContextItemSelected(MenuItem item) {
    if (item.getItemId() == R.id.menu_riwayat_hapus) {
      long id = ((AdapterView.AdapterContextMenuInfo)item.getMenuInfo()).id;
      int jumlahDihapus = db.delete("lokasi", "_id=?", new String[] {String.valueOf(id)});
      Toast.makeText(this, jumlahDihapus + " berhasil dihapus.", Toast.LENGTH_SHORT).show();
      Cursor cursor = db.rawQuery("SELECT * FROM lokasi", null);
      riwayatCursorAdapter.changeCursor(cursor);
      riwayatCursorAdapter.notifyDataSetChanged();
      return true;
    }
    return false;
  }

}

Pada kode program di atas, saya mendaftarkan context menu ListView dengan menggunakan registerForContextMenu(). Setelah itu, saya membuat menu ini pada onCreateContextMenu(). Bila salah satu item di context menu dipilih, saya bisa mengetahui id atau posisi (di ListView) yang sedang aktif dengan memanggil getMenuInfo() dari MenuItem. Pada contoh di atas, saya menghapus record dengan nilai field _id yang sesuai dengan item menu terpilih. Saya bisa menghapus record dengan memanggil method delete() dari SQLiteDatabase (selain itu, saya juga bisa membuat query SQL secara manual dengan memanggil rawQuery()). Setelah record dihapus, saya perlu memperbaharui ListView. Oleh sebab itu, saya men-query ulang isi tabel untuk memperoleh Cursor terbaru dan memakainya sebagai Cursor terbaru dengan memanggil method changeCursor() dari CursorAdapter. Saya juga perlu memanggil notifyDataSetChanged() dari CursorAdapter untuk memberi tahu ListView bahwa ia perlu digambar ulang. Pada aplikasi yang serius, seluruh kode program yang berhubungan dengan database sebaiknya dikerjakan pada thread terpisah agar tidak membuat UI menjadi tidak responsif. Pada contoh disini, saya tidak melakukannya agar kode program tetap sederhana.

Mencari Arah Dengan Kompas Digital Di Android

Pada artikel Mencari Lokasi Dengan GPS Di Android, saya menggunakan GPS untuk menentukan posisi perangkat Android di peta. Pada artikel ini, saya akan menambahkan sebuah informasi lain berupa arah perangkat pada saat posisi tersebut diambil. Beberapa (tidak semua!) perangkat Android dilengkapi dengan sensor yang disebut sebagai magnetometer atau sering disebut sebagai kompas digital yang mengukur nilai dalam satuan micro-Tesla (uT).

Sama seperti pada kompas analog, sensor ini pada dasarnya dipengaruhi oleh medan magnet disekitarnya, terutama oleh medan magnet bumi. Akan tetapi, berbeda pada kompas analog yang hanya mengukur pada satu arah, perangkat Android biasanya menggunakan vector magnetometer yang mengukur medan magnet pada masing-masing 3 arah (x, y, dan z). Untuk menunjukkannya, saya akan menampilkan medan magnet yang dibaca pada 3 vector tersebut dengan menambahkan kode program berikut ini di LokasiActivity:

public class LokasiActivity extends Activity implements LocationListener, SensorEventListener {

    ...

    private float magnetX, magnetY, magnetZ;
    private SensorManager sensorManager;

    @Override
    protected void onStart() {
        ...
        sensorManager = (SensorManager) getSystemService(Context.SENSOR_SERVICE);
        Sensor magnetometer = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD);
        if (magnetometer != null) {
            sensorManager.registerListener(this, magnetometer, SensorManager.SENSOR_DELAY_UI);
        }
    }

    @Override
    public void onLocationChanged(Location location) {
        ...
        String strOutput = "DMS: " + lokasiTerakhir.koordinatDMS() + "n" +
            "DD: " + lokasiTerakhir.koordinatDD() + "n" +
            "Altitude: " + lokasiTerakhir.getAltitude() + " mn" +
            "Akurasi: " + lokasiTerakhir.getAkurasi() + " mn" +
            "Magnet X: " + magnetX + " uTn" +
            "Magnet Y: " + magnetY + " uTn" +
            "Magnet Z: " + magnetZ + " uTn";
        output.setText(strOutput);
        ...
    }

    @Override
    protected void onPause() {
        ...
        sensorManager.unregisterListener(this);
    }

    ...

    @Override
    public void onSensorChanged(SensorEvent event) {
        magnetX = event.values[0];
        magnetY = event.values[1];
        magnetZ = event.values[2];
    }

    @Override
    public void onAccuracyChanged(Sensor sensor, int accuracy) {

    }

}

Pada saat aplikasi dijalankan, saya akan memperoleh nilai besarnya medan magnet pada sumbu x, y, dan z. Walaupun demikian, saya tidak bisa memperoleh sesuatu yang bisa dimengerti atau berguna secara langsung dari nilai ini. Yang saya butuhkan adalah sebuah nilai tunggal seperti azimuth. Nilai azimuth adalah derajat yang menunjukkan jarak dari utara searah dengan jarum jam (misalnya, nilai azimuth untuk posisi di utara adalah 0 derajat, posisi timur 90 derajat, posisi selatan 180 derajat dan posisi barat 270 derajat).

Beruntungnya, Android Sensor API sudah menyediakan method yang akan melakukan kalkulasi rumit bagi saya. Langkah pertama yang saya perlu saya lakukan adalah mendapatkan nilai rotation matrix (R) yang berguna untuk memetakan koordinat perangkat ke koordinat dunia nyata. Koordinat dunia nyata adalah koordinat dimana X selalu menghadap timur, Y selalu menghadap kutub utara dan Z selalu menghadap ke langit, tanpa peduli pada orientasi perangkat. Untuk mendapatkan nilai R, saya perlu memperoleh nilai dari sensor accelerometer dan magnetometer. Nilai yang diperoleh dari sensor akan dilewatkan sebagai parameter pada method getRotationMatrix() milik SensorManager. Sebagai contoh, saya membatalkan perubahan sebelumnya dan mengubah kode program saya menjadi seperti berikut ini:

public class LokasiActivity extends Activity implements LocationListener, SensorEventListener {

    ...

    private float[] magnet, percepatan;
    private SensorManager sensorManager;

    @Override
    protected void onStart() {
        ...
        sensorManager = (SensorManager) getSystemService(Context.SENSOR_SERVICE);
        Sensor magnetometer = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD);
        if (magnetometer != null) {
            sensorManager.registerListener(this, magnetometer, SensorManager.SENSOR_DELAY_UI);
        }
        Sensor accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
        if (accelerometer != null) {
            sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_UI);
        }
    }

    @Override
    public void onLocationChanged(Location location) {
        // Hitung rotasi
        if ((magnet!=null) && (percepatan!=null)) {
            float R[] = new float[9];
            float I[] = new float[9];
            if (SensorManager.getRotationMatrix(R, I, percepatan, magnet)) {
                // Proses disini nanti!
            }
        }
        ...
    }

    ...

    @Override
    protected void onPause() {
        super.onPause();
        locationManager.removeUpdates(this);
        sensorManager.unregisterListener(this);
    }


    @Override
    public void onSensorChanged(SensorEvent event) {
        if (event.sensor.getType() == Sensor.TYPE_ACCELEROMETER) {
            percepatan = event.values;
        } else if (event.sensor.getType() == Sensor.TYPE_MAGNETIC_FIELD) {
            magnet = event.values;
        }
    }

    ...
}

Pada kode program di atas, saya sudah memperoleh matrix R yang mewakili rotation matrix. Sekarang, saya bisa menggunakan nilai tersebut untuk dipakai pada method getOrientation() di SensorManager, seperti pada contoh berikut ini:

...
@Override
public void onLocationChanged(Location location) {
  // Hitung rotasi
  float azimuth = 0;
  if ((magnet!=null) && (percepatan!=null)) {
    float R[] = new float[9];
    float I[] = new float[9];
    if (SensorManager.getRotationMatrix(R, I, percepatan, magnet)) {
      float orientation[] = new float[3];
      azimuth = orientation[0];
    }
  }
  ...
}
....

Nilai azimuth yang dikembalikan oleh getOrientation() berada dalam satuan radian dimana nilai positif mewakili arah berlawanan dengan jarum jam. Untuk menerjemahkan nilai ini ke dalam derajat, saya menambahkan kode program berikut ini:

...
azimuth = (float) Math.toDegrees(orientation[0]);
if (orientation[0] < 0) {
    azimuth += 360;
}
...

Setelah itu, saya bisa menambahkan property baru pada Lokasi untuk menampung informasi azimuth. Saya juga perlu mengubah sebuah constructor pada class tersebut sehingga dapat menerima nilai azimuth. Ssebagai langkah terakhir, saya tinggal menampilkan nilai azimuth dari Lokasi ke dalam TextView (bersamaan dengan nilai yang diperoleh dari GPS).

Nilai azimuth yang diperoleh adalah azimuth compass yang relatif terhadap magnetic north bukan terhadap true north. Walaupun mendekati true north, nilai magnetic north selalu memiliki selisih tergantung pada wilayah dan waktu dimana pengguna berada. Selisih ini disebut sebagai magnetic declination. Struktur wilayah, jumlah besi yang terkandung di dalam tanah dan faktor lainnya merupakan contoh faktor yang mempengaruhi magnetic declination. Selain itu, metal dan magnet di sekitar perangkat bisa mempengaruhi nilai yang dibaca oleh magnetometer. Kelemahan lainnya adalah nilai azimuth hanya akurat bila perangkat berada dalam keadaan mendatar terhadap bumi dimana layar menghadap ke atas (sama seperti layaknya saat kompas digunakan). Bila perangkat berada dalam keadaan vertikal, gimbal lock akan menyebabkan hasil perhitungan getOrientation() milik SensorManager menjadi tidak akurat sehingga dibutuhkan perhitungan yang lebih kompleks untuk memperoleh azimuth yang diharapkan.

Mencari Lokasi Dengan GPS Di Android

Saat ini sudah terdapat banyak perangkat Android yang dilengkapi dengan perangkat GPS receiver. Hardware ini akan membaca informasi dari satelit GPS untuk menentukan lokasi perangkat. Jarak perangkat ke sebuah satelit dapat ditentukan dengan mengirim gelombang radio ke satelit dan mengukur selisih waktu dari receiver ke satelit. Setelah menemukan jarak untuk beberapa satelit, posisi perangkat dapat ditemukan dengan menggunakan perhitungan yang disebut trilateration. Sebagai programmer untuk perangkat Android, saya tidak perlu terlibat dalam proses yang kompleks ini karena saya hanya perlu menggunakan class yang ada di package android.location untuk memperoleh data lokasi melalui GPS.

Untuk bisa memakai GPS, saya mendaftarkan penggunaan permission berikut ini pada AndroidManifest.xml:

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

Pada Android API, sebuah lokasi diwakili oleh class Location. Karena class ini terlalu umum, saya akan membuat versi yang lebih spesifik dengan kebutuhan saya (domain class) seperti berikut ini:

public class Lokasi {

    private String nama;
    private double longitude;
    private double latitude;    
    private double altitude;
    private float akurasi;

    public Lokasi(Location location) {
        this.longitude = location.getLongitude();
        this.latitude = location.getLatitude();  
        this.altitude = location.getAltitude();
        this.akurasi = location.getAccuracy();
    }

    public Lokasi(double longitude, double latitude, double altitude, float akurasi) {
        this.longitude = longitude;
        this.latitude = latitude;
        this.altitude = altitude;
        this.akurasi = akurasi;
    }

    public Lokasi(String nama, double longitude, double latitude, double altitude, float akurasi) {
        this(longitude, latitude, altitude, akurasi);
        this.nama = nama;                
    }

    public String getNama() {
        return nama;
    }

    public void setNama(String nama) {
        this.nama = nama;
    }

    public double getLongitude() {
        return longitude;
    }

    public void setLongitude(double longitude) {
        this.longitude = longitude;
    }

    public double getLatitude() {
        return latitude;
    }

    public void setLatitude(double latitude) {
        this.latitude = latitude;
    }

    public double getAltitude() {
        return altitude;
    }

    public void setAltitude(double altitude) {
        this.altitude = altitude;
    }

    public float getAkurasi() {
        return akurasi;
    }

    public void setAkurasi(float akurasi) {
        this.akurasi = akurasi;
    }

}

Nilai latitude (posisi horizontal terhadap khatulistiwa) dan longitude (posisi vertikal terhadap prime meridian) dapat dipakai untuk menentukan posisi sebuah lokasi di bumi. Nilai yang dimiliki oleh Location (sekaligus dipakai di Lokasi) berada dalam format yang disebut sebagai Decimal Degrees (DD). Sebagai contoh, nilai latitude berupa 38,8897 derajat sama dengan 38 derajat 53 menit 23 detik utara pada format Degrees, Minutes, and Seconds (DMS). Angka positif pada latitude mewakili utara dan angka negatif mewakili selatan relatif terhadap khatulistiwa. Pada longitude, angka positif mewakili timur dan negatif mewakili barat relatif terhadap prime meridian.

Agar lebih mudah dibaca oleh pengguna, saya akan menambahkan method koordinatDMS() dan koordinatDD() di class Lokasi yang akan mengembalikan koordinat lokasi dalam format yang dikehendaki seperti pada:

class Lokasi {

  ...

  public String koordinatDMS() {
    return Location.convert(latitude, Location.FORMAT_SECONDS) + " " +
      Location.convert(longitude, Location.FORMAT_MINUTES);
  }

  public String koordinatDD() {
    return latitude + "," + longitude;
  }

}

Nilai altitude pada Location (sekaligus dipakai di Lokasi) adalah nilai dalam satuan meter yang mewakili ketinggian perangkat dihitung mulai dari posisi WGS-84 reference ellipsoid. Perlu diperhatikan bahwa nilai ini bukan ketinggian yang dihitung dari permukaan laut. Hal ini karena nilai permukaan laut yang sesungguhnya terlalu kompleks untuk diproses oleh GPS receiver.

Nilai accuracy pada Location (sekaligus dipakai sebagai akurasi di Lokasi) menunjukkan seberapa akurat nilai latitude dan longitude yang diperoleh dari GPS receiver. Bila sebuah lingkaran digambar dengan jari-jari sepanjang nilai accuracy (dalam satuan meter), maka ada kemungkinan 68% lokasi perangkat berada di dalam lingkaran ini.

Untuk memperoleh sebuah Location, saya perlu memanggil method requestLocationUpdates() dari LocationManager untuk mendaftarkan sebuah LocationListener. Pada listener tersebut, bila lokasi dari GPS telah diperoleh, method onLocationChanged() akan dipanggil. Sebagai latihan, saya membuat sebuah activity dengan nama LokasiActivity yang isinya seperti berikut ini:

public class LokasiActivity extends Activity implements LocationListener {

    private LocationManager locationManager;
    private Lokasi lokasiTerakhir;

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

    @Override
    protected void onStart() {
        super.onStart();
        locationManager = (LocationManager) getSystemService(Context.LOCATION_SERVICE);
        locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0, this);
    }

    @Override
    public void onLocationChanged(Location location) {
        lokasiTerakhir = new Lokasi(location);
        TextView output = (TextView) findViewById(R.id.output);
        String strOutput = "DMS: " + lokasiTerakhir.koordinatDMS() + "n" +
            "DD: " + lokasiTerakhir.koordinatDD() + "n" +
            "Altitude: " + lokasiTerakhir.getAltitude() + " mn" +
            "Akurasi: " + lokasiTerakhir.getAkurasi() + " mn";
        output.setText(strOutput);
    }

    @Override
    public void onStatusChanged(String provider, int status, Bundle extras) {

    }

    @Override
    public void onProviderEnabled(String provider) {

    }

    @Override
    public void onProviderDisabled(String provider) {

    }

    @Override
    protected void onPause() {
        super.onPause();
        locationManager.removeUpdates(this);
    }

}

Bila activity di atas dijalankan, informasi dari GPS akan ditampilkan pada sebuah TextView.

Apa yang bisa saya lakukan selain hanya melihat koordinat dari GPS? Sebagai contoh, saya men-share koordinat ini. Untuk itu, saya membuat sebuah deklarasi menu XML seperti berikut ini:

<menu ...>

   <item android:id="@+id/menu_share" app:showAsAction="ifRoom"
      android:title="Share"
      android:actionProviderClass="android.widget.ShareActionProvider" />

</menu>

Saya kemudian memakai menu baru ini dengan menambahkan method onCreateOptionsMenu() pada LokasiActivity:

public class LokasiActivity extends Activity implements LocationListener {

  prvate ShareActionProvider shareActionProvider;
  ...

  @Override
  public boolean onCreateOptionsMenu(Menu menu) {
    getMenuInflater().inflate(R.menu.menu_main, menu);
    MenuItem menuShare =  menu.findItem(R.id.menu_share);
    shareActionProvider = (ShareActionProvider) menuShare.getActionProvider();
    return true;
  }

}

Selanjutnya, saya menentukan nilai lokasi yang bisa di-share dengan menambahkan kode program berikut ini pada method onLocationChanged():

public class LokasiActivity extends Activity implements LocationListener {

  ...

  @Override
  public void onLocationChanged(Location location) {
    ...

    // Share lokasi DD
    Intent lokasiDDIntent = new Intent();
    lokasiDDIntent.setAction(Intent.ACTION_SEND);
    lokasiDDIntent.putExtra(Intent.EXTRA_TEXT, lokasiTerakhir.koordinatDD());
    lokasiDDIntent.setType("text/plain");
    shareActionProvider.setShareIntent(lokasiDDIntent);
  }

}

Selain itu, saya juga bisa menambahkan menu untuk menampilkan lokasi pada aplikasi peta default seperti Google Map atau Google Earth. Sebagai contoh, saya menambahkan sebuah item menu baru di deklarasi XML:

<menu ...>

  ...

  <item android:id="@+id/menu_tampil" app:showAsAction="ifRoom"
     android:title="Tampilkan" />

</menu>

Setelah itu, saya menambahkan aksi bila menu tersebut dipilih dengan menggunakan kode program seperti berikut ini:

public class LokasiActivity extends Activity implements LocationListener {

  ...

  @Override
  public boolean onCreateOptionsMenu(Menu menu) {
    ...
    // Tampilkan di aplikasi peta
    menu.findItem(R.id.menu_tampil).setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() {
      @Override
      public boolean onMenuItemClick(MenuItem item) {
        if (lokasiTerakhir != null) {
          Intent intent = new Intent(Intent.ACTION_VIEW);
          intent.setData(Uri.parse("geo:" + lokasiTerakhir.koordinatDD()));
          if (intent.resolveActivity(getPackageManager()) != null) {
            startActivity(intent);
          }
        }
        return true;
      }
    });

    return true;
  }

  ...

}

Sekarang, bila menu Tampilkan dipilih, lokasi akan ditampilkan oleh aplikasi yang dapat menampilkan koordinat peta seperti Google Map dan Google Earth.

Mengatur Wilayah Focus Pada Kamera Di Android

Kamera pada dasarnya bekerja mirip seperti pada mata manusia. Bedanya, pada mata manusia, tergantung pada jarak benda yang dilihat, lensa mata manusia akan melakukan perubahan bentuk. Proses yang disebut accomodation ini dibutuhkan agar cahaya tetap dipantulkan secara benar pada retina. Sebagai contoh, pada saat melihat benda yang jauh, lensa mata akan menjadi pipih (sehingga terlihat tipis) dan pada saat melihat benda dalam jarak sangat dekat, lensa mata akan berkontraksi sehingga menjadi tebal.

Lalu bagaimana dengan lensa pada kamera? Karena tidak bisa fleksibel seperti mata manusia, lensa kamera perlu melakukan pergeseran posisi. Bila tidak tepat, gambar yang dihasilkan oleh kamera akan terlihat kabur. Pada era manual focus, pengguna kamera perlu mengukur jarak objek yang hendak dipotret secara manual lalu mengubah posisi lensa kamera sesuai dengan jarak tersebut. Kamera pada perangkat Android umumnya tidak memiliki kemampuan manual focus, melainkan auto focus.

Berkat auto focus, pengguna tidak perlu mengatur fokus secara manual. Driver kamera akan melakukan kalkulasi dan menentukan apakah gambar buram atau tidak. Bila buram, fokus akan terus berubah hingga tercapai sebuah gambar yang jernih. Selain itu, bila method startFaceDetection() dari Camera dipanggil, driver kamera akan mendeteksi wajah dan secara otomatis melakukan fokus ke wajah supaya tidak ada wajah yang ‘buram’. Bila tidak memotret wajah, driver kamera juga bisa melakukan auto focus berdasarkan informasi wilayah di preview kamera yang diberikan oleh pengguna melalui method setFocusAreas() milik Camera.Parameters. Informasi ini hanya sebagai masukan bagi driver kamera sehingga hasil akhir fokus kamera bisa saja berbeda.

Sebagai latihan, saya akan meneruskan latihan pada proyek Memakai Efek Warna Di Kamera Pada Android dan menambahkan fitur dimana pengguna bisa menentukan wilayah dan nilai fokus. Saya akan membuat sebuah View yang memiliki ukuran yang sama dengan CameraPreview dimana pengguna dapat menarik layar untuk membuat persegi panjang. Sebagai contoh, saya akan memberinya nama FocusEditView dengan isi kode program seperti berikut ini:

public class FocusEditView extends View {

    private CameraPreview cameraPreview;
    private RectF kotakFokus;
    private Paint paintKotak;
    private boolean drawing;
    private int width, height;

    public FocusEditView(Context context, CameraPreview cameraPreview) {
        super(context);
        paintKotak = new Paint();
        paintKotak.setStyle(Paint.Style.STROKE);
        paintKotak.setColor(Color.YELLOW);
        paintKotak.setStrokeWidth(2);
        drawing = false;
        this.cameraPreview = cameraPreview;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        if (kotakFokus != null) {
            canvas.drawRect(kotakFokus, paintKotak);
        }
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                kotakFokus = new RectF(event.getX(), event.getY(), event.getX(), event.getY());
                drawing = true;
                break;
            case MotionEvent.ACTION_MOVE:
                if (drawing) {
                    kotakFokus.set(kotakFokus.left, kotakFokus.top, event.getX(), event.getY());
                    invalidate();
                }
                break;
            case MotionEvent.ACTION_UP:
                kotakFokus.set(kotakFokus.left, kotakFokus.top, event.getX(), event.getY());
                invalidate();
                drawing = false;                
                break;
        }
        return true;
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        width = cameraPreview.getMeasuredWidth();
        height = cameraPreview.getMeasuredHeight();
        setMeasuredDimension(width, height);
    }

    public Camera.Area getArea() {
        RectF kotakF = new RectF();
        Matrix matrix = new Matrix();
        matrix.postScale(2000.0f / width, 2000.0f / height);
        matrix.postTranslate(-1000, -1000);
        matrix.mapRect(kotakF, kotakFokus);
        Rect kotak = new Rect();
        kotakF.round(kotak);
        Camera.Area hasil = new Camera.Area(kotak, 1000);
        return hasil;
    }

}

Pada View di atas, pengguna dapat menggambar sebuah kotak dengan menahan dan menggeser jarinya di layar sentuh. Saya menyimpan kotak yang dibuat oleh pengguna dalam sebuah variabel bernama kotakFokus yang bertipe RectF. Agar kotak ini dapat dipakai sebagai wilayah fokus kamera, saya perlu menerjemahkannya ke dalam koordinat yang dibutuhkan oleh Camera.Area. Pada Canvas, titik (0,0) adalah posisi kiri atas layar dan titik (width,height) adalah posisi kanan bawah layar. Sementara itu, pada Camera.Area, titik (-1000,-1000) adalah posisi kiri atas layar dan titik (1000,1000) adalah posisi kanan bawah layar. Nilai 0 sampai 1.000 ini tidak dipengaruhi oleh ukuran layar sehingga layar selalu dianggap sebagai sebuah kotak dengan ukuran 2.000 x 2.000. Untuk menerjemahkan koordinat Canvas menjadi koordinat Camera.Area, saya menggunakan bantuan Matrix yang diaplikasikan pada RectF dengan menggunakan method mapRect(). Setelah itu, saya perlu menerjemahkan RectF yang memiliki nilai dalam tipe float menjadi Rect yang memiliki nilai dalam tipe integer. Caranya adalah dengan memanggil method round() milik RectF. Setelah selesai, saya menghasilkan sebuah Camera.Area dengan nilai weight berupa 1.000 yang berarti fokus maksimal pada wilayah ini.

FocusEditView harus ditampilkan secara transparan di atas CameraPreview sehingga memberikan kesan seolah-olah pengguna menggambar kotak pada CameraPreview. Untuk itu, saya menambahkan baris berikut ini pada method onCreateView() di PreviewFragment:

public class PreviewFragment extends Fragment implements View.OnClickListener {

    private CameraPreview cameraPreview;
    private FocusEditView focusEditView;

    public PreviewFragment() {}

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        ...
        FrameLayout framePreview = (FrameLayout) view.findViewById(R.id.preview);
        cameraPreview = new CameraPreview(activity, activity.getCamera());
        framePreview.addView(cameraPreview);
        focusEditView = new FocusEditView(activity, cameraPreview);
        focusEditView.setBackgroundResource(android.R.color.transparent);
        FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT);
        focusEditView.setLayoutParams(lp);
        framePreview.addView(focusEditView);
        ...
    }

}

Sekarang, bila saya menjalankan aplikasi, saya bisa menggambar kotak di layar seperti pada gambar berikut ini:

Menggambar Kotak Pada Preview Kamera

Menggambar Kotak Pada Preview Kamera

Untuk mengatur fokus pada kotak yang digambar pengguna, saya menambahkan sebuah Button baru di preview_fragment.xml dengan id berupa btnFokus. Handler untuk Button ini akan memanggil method pilihFokus() yang isinya seperti berikut ini:

public void pilihFokus(final Camera.Area areaFokus) {
  camera.cancelAutoFocus();
  Camera.Parameters params = camera.getParameters();
  params.setFocusMode(Camera.Parameters.FOCUS_MODE_AUTO);
  List<Camera.Area> daftarFokus = new ArrayList<>();
  daftarFokus.add(areaFokus);
  params.setFocusAreas(daftarFokus);
  camera.setParameters(params);
  camera.autoFocus(new Camera.AutoFocusCallback() {
    @Override
    public void onAutoFocus(boolean success, Camera camera) {
        Log.d("MyCam", "Mengatur fokus ke " + areaFokus.rect + ". Status sukses: " + success);
    }
  });
}

Pada kode program di atas, saya berpindah ke modus Camera.Parameters.FOCUS_MODE_AUTO dengan menggunakan setFocusMode(). Setelah itu, saya mengatur wilayah yang disarankan sebagai sasaran fokus dengan menggunakan setFocusAreas(). Setelah itu, saya harus memanggil method autoFocus() dari Camera agar pencarian fokus dilakukan.

Memakai Efek Warna Di Kamera Pada Android

Bagi seorang engineer seperti saya, sebuah kamera hanyalah sebuah sensor yang membaca data cahaya. Namun bagi kalangan umum, kamera di perangkat Android sering dianggap sebagai alat yang penting. Tidak sedikit pengguna yang memilih perangkat Android berdasarkan kejernihan kameranya. Untuk memanfaatkan kamera secara maksimal, pengguna juga sering kali men-install aplikasi yang menawarkan efek menarik. Sebagai latihan, pada tulisan kali ini, saya akan mencoba membuat sebuah aplikasi yang mengambil gambar dari kamera dan memanfaatkan efek warna bawaan driver kamera.

Saya akan mulai dengan membuat sebuah proyek Android baru yang menggunakan API 19 (Android 4.4 KitKat). Pada saat membuat proyek, saya memilih Blank Activity with Fragment. Karena ingin menggunakan kamera dan menulis hasil rekaman kamera, saya perlu menambahkan izin penggunaan permission berikut ini pada AndroidManifest.xml:

<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

Untuk melakukan preview kamera, saya akan membuat sebuah turunan SurfaceView baru seperti yang saya jumpai pada dokumentasi Android:

public class CameraPreview extends SurfaceView implements SurfaceHolder.Callback {

    private SurfaceHolder holder;
    private Camera camera;

    public CameraPreview(Context context, Camera camera) {
        super(context);
        this.camera = camera;
        holder = getHolder();
        holder.addCallback(this);
    }

    @Override
    public void surfaceCreated(SurfaceHolder holder) {
        try {
            camera.setPreviewDisplay(holder);
            camera.startPreview();
        } catch (IOException e) {
            Log.d("CameraPreview", "Error setting camera preview: " + e.getMessage());
        }
    }

    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
        if (this.holder.getSurface() == null) {
            return;
        }
        try {
            camera.stopPreview();
        } catch (Exception e) {}
        try {
            camera.setPreviewDisplay(this.holder);
            camera.startPreview();
        } catch (Exception e) {
            Log.d("CameraPreview", "Error starting camera preview: " + e.getMessage());
        }
    }

    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {

    }
}

Setelah itu, saya menambahkan sebuah fragment baru dengan PreviewFragment.java yang isinya seperti berikut ini:

public class PreviewFragment extends Fragment {

    public PreviewFragment() {}

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        MainActivity activity = (MainActivity) getActivity();
        View view = inflater.inflate(R.layout.preview_fragment, container, false);
        FrameLayout framePreview = (FrameLayout) view.findViewById(R.id.preview);
        framePreview.addView(new CameraPreview(activity, activity.getCamera()));
        return view;
    }

}

PreviewFragment adalah sebuah fragment yang akan menampilkan preview kamera. Fragment ini membutuhkan sebuah android.hardware.Camera yang diperolehnya dengan memanggil method getCamera() dari MainActivity. Selain itu, saya juga perlu mendefinisikan layout yang dibutuhkan oleh fragment ini. Sebagai contoh, saya membuat preview_fragment.xml yang isinya berupa:

<RelativeLayout ...>

    <FrameLayout
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        android:id="@+id/preview" />

    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Efek Warna"
        android:id="@+id/btnEfekWarna"
        android:layout_gravity="left|top" />

</RelativeLayout>

Sekarang, saya akan mengubah kode program MainActivity agar menampilkan PreviewFragment, seperti:

public class MainActivity extends Activity {

    private Camera camera;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        camera = Camera.open();
        Camera.Parameters params = camera.getParameters();
        params.setJpegQuality(100);
        camera.setParameters(params);
        camera.setDisplayOrientation(90);

        getFragmentManager().beginTransaction()
            .add(R.id.container, new PreviewFragment())
            .commit();
    }

    @Override
    protected void onPause() {
        super.onPause();
        if (camera != null) {
            camera.release();
        }
    }

    public Camera getCamera() {
        return camera;
    }

}

Google menambahkan API baru untuk mengakses kamera pada API 21 (Android 5.0 Lollipop). Selain itu, kode program yang mengakses kamera dengan android.hardware.Camera sudah di-deprecate pada API 21. Karena tidak memiliki perangkat Lollipop, saya akan tetap membuat kode program yang menggunakan android.hardware.Camera. Pada kode program di atas, saya mengakses kamera belakang dengan memanggil method open() dari android.hardware.Camera. Selain itu, agar latihan ini tetap sederhana, saya menggangap orientasi kamera selalu tegak (dengan setDisplayOrientation(90)). Tanpa kode program yang memanggil setDisplayOrientation() milik Camera sesuai dengan orientasi kamera, hasil preview akan ‘miring’ saat ditampilkan kamera berada dalam orientasi landscape.

Aplikasi kamera biasanya ditampilkan secara full-screen. Untuk itu, saya mengubah android:theme di AndroidManifest.xml menjadi seperti berikut ini:

android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen"

Sekarang, bila saya menjalankan aplikasi, saya akan memperoleh hasil seperti pada gambar berikut ini:

Hasil preview kamera

Hasil preview kamera

Sampai disini, saya sudah bisa melihat preview kamera, akan tetapi saya masih belum bisa mengubah efek warna. Saya perlu membuat sebuah fragment baru dimana pengguna bisa memilih efek warna yang diinginkan. Untuk itu, saya membuat sebuah class baru dengan nama EfekWarnaFragment.java yang isinya seperti berikut ini:

public class EfekWarnaFragment extends Fragment implements AdapterView.OnItemClickListener {

    private Camera camera;
    private EfekWarnaAdapter efekWarnaAdapter;

    public EfekWarnaFragment() {}

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        MainActivity activity = (MainActivity) getActivity();
        camera = activity.getCamera();
        ListView daftarEfek = new ListView(activity);
        daftarEfek.setOnItemClickListener(this);
        TextView label = new TextView(activity);
        efekWarnaAdapter = new EfekWarnaAdapter(camera.getParameters().getSupportedColorEffects());
        daftarEfek.setAdapter(efekWarnaAdapter);
        return daftarEfek;
    }

    @Override
    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
        camera.stopPreview();
        Camera.Parameters params = camera.getParameters();
        params.setColorEffect((String) efekWarnaAdapter.getItem(position));
        camera.setParameters(params);
        getFragmentManager().popBackStack();
    }

    class EfekWarnaAdapter extends BaseAdapter {

        List<String> daftarEfek;

        public EfekWarnaAdapter(List<String> daftarEfek) {
            this.daftarEfek = daftarEfek;
        }

        @Override
        public int getCount() {
            return daftarEfek.size();
        }

        @Override
        public Object getItem(int position) {
            return daftarEfek.get(position);
        }

        @Override
        public long getItemId(int position) {
            return position;
        }

        @Override
        public View getView(int position, View convertView, ViewGroup parent) {
            TextView view;
            if (convertView == null) {
                view = new TextView(getActivity());
                view.setGravity(Gravity.CENTER);
                view.setTextSize(20);
            } else {
                view = (TextView) convertView;
            }
            view.setText((String) getItem(position));
            return view;
        }

    }

}

Pada kode program di atas, saya menggunakan getSupportedColorEffects() milik Camera.Parameters untuk memperoleh sebuah List yang berisi daftar efek warna yang disediakan oleh driver kamera. Setelah itu, saya menampilkan daftar efek warna tersebut dalam sebuah ListView. Bila pengguna memilih salah satu efek warna, saya kemudian menggunakan setColorEffect() milik Camera.Parameters untuk mengaplikasikan efek warna tersebut pada kamera.

Kali ini, bila saya menjalankan aplikasi dan menyentuh tombol Efek Warna, saya akan memperoleh hasil seperti pada gambar berikut ini:

Daftar efek warna yang didukung oleh driver kamera

Daftar efek warna yang didukung oleh driver kamera

Bila saya memilih salah satu efek warna, misalnya negative, saya akan memperoleh hasil seperti pada gambar berikut ini:

Hasil preview kamera setelah memilih efek negative

Hasil preview kamera setelah memilih efek negative

Sebagai langkah terakhir, saya akan menambahkan kode program yang menyimpan file gambar bila layar preview disentuh. Untuk itu, saya menambahkan OnTouchListener pada CameraPreview seperti:

public class CameraPreview extends SurfaceView implements SurfaceHolder.Callback, View.OnTouchListener {

  ...

  public CameraPreview(Context context, Camera camera) {
    ...
    setOnTouchListener(this);
  }

  ...

  @Override
  public boolean onTouch(View v, MotionEvent event) {
    Camera.PictureCallback pictureCallback = new Camera.PictureCallback() {
      @Override
      public void onPictureTaken(byte[] data, Camera camera) {
        File picDir = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), "MyCam");
        if (!picDir.exists()) {
          picDir.mkdirs();
        }
        String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
        try {
          File outputFile = new File(picDir, "IMG_" + timeStamp + ".jpg");
          FileOutputStream fos = new FileOutputStream(outputFile);
          fos.write(data);
          fos.close();
          MediaScannerConnection.scanFile(getContext(), new String[]{outputFile.getPath()}, new String[]{"image/jpeg"}, null);
          camera.startPreview();
        } catch (IOException e) {
          Log.e("MyCam", "Terjadi kesalahan saat menulis file gambar", e);
        }
      }
    };
    camera.takePicture(null, null, pictureCallback);
    return false;
  }

}

Untuk menyimpan hasil preview di kamera ke dalam sebuah file JPEG, saya memanggil method takePicture() milik Camera. Pada method ini, saya melewatkan sebuah Camera.PictureCallback yang akan dikerjakan saat file gambar telah diolah oleh driver kamera. onPictureTaken() mengandung byte[] yang merupakan sebuah gambar dalam format JPEG. Yang perlu saya lakukan adalah menulis file ini ke direktori penyimpanan gambar. Selain itu, saya juga perlu memanggil method scanFile() milik MediaScannerConnection agar file gambar baru ini dapat langsung dilihat melalui aplikasi lain.

Sekarang, saya bisa membuat file gambar dari kamera dengan menyentuh preview kamera. Walaupun demikian, rotasi pada preview kamera berbeda dengan yang ada di file JPEG yang dihasilkan. Ternyata camera.setDisplayOrientation(90) hanya memiliki efek pada preview, tetapi tidak berpengaruh pada gambar yang dihasilkan. Oleh sebab itu, saya menambahkan setRotation() milik Camera.Parameters sehingga method onCreate() pada MainActivity terlihat seperti berikut ini:

@Override
protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  setContentView(R.layout.activity_main);
  camera = Camera.open();
  Camera.Parameters params = camera.getParameters();
  params.setJpegQuality(100);
  params.setRotation(90);
  camera.setParameters(params);
  camera.setDisplayOrientation(90);

  getFragmentManager().beginTransaction()
    .add(R.id.container, new PreviewFragment())
    .commit()
}

Sekarang, hasil file gambar yang diperoleh akan memiliki orientasi yang sama dengan orientasi di preview (sama-sama portrait).

Membuat Aplikasi Android Yang Diam-Diam Merekam Video

Pada latihan kali ini, saya akan membuat sebuah aplikasi Android yang akan merekam video secara diam-diam melalui kamera depan tanpa menampilkan preview. Proses perekaman akan tetap berlangsung di balik layar walaupun pengguna membuka aplikasi lain. Tentu saja latihan kali ini tidak banyak gunanya selain untuk membuat ‘kejutan’ bagi teman yang meminjam perangkat Android. Selain itu, karena API Android dirancang untuk menampilkan preview video, maka apa yang saya lakukan pada latihan kali ini bukanlah sesuatu yang resmi didukung dan tidak bekerja dengan baik pada seluruh perangkat Android.

Seperti biasa, saya akan mulai dengan membuat proyek baru di Android Studio. Saya menggunakan perangkat Android versi KitKat. Karena ingin merekam video (termasuk suara) dan menyimpan hasilnya secara publik, saya perlu menambahkan penggunaan permission berikut ini pada AndroidManifest.xml:

<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

Agar bisa merekam video, dibutuhkan sebuah SurfaceView untuk keperluan preview. Karena aplikasi berjalan dibalik layar tanpa terikat pada sebuah activity, maka saya akan menambahkan komponen preview ini dalam ukuran 1×1 piksel (sehingga tidak terlihat oleh pengguna) secara langsung pada layar dengan menggunakan WindowManager. Agar bisa melakukan ini, saya membutuhkan permission berikut ini:

<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />

Mengapa saya tidak bisa menggunakan activity? Karena sebuah activity akan berhenti pada saat pengguna beralih ke activity lainnya (membuka halaman utama atau aplikasi lain). Agar perekaman bisa tetap berlanjut, saya perlu membuat sebuah service yang tetap berjalan di balik layar. Service ini perlu melakukan proses perekamanan video di dalam thread yang berbeda. Untuk itu, saya membuat sebuah class yang mewakili sebuah thread dengan nama RecorderThread yang isinya seperti berikut ini:

public class RecorderThread extends Thread {

    private boolean aktif;
    private int serviceId;
    private final VideoRecordService recorderService;
    private Camera camera;

    public RecorderThread(int serviceId, VideoRecordService recorderService, Camera camera) {
        this.serviceId = serviceId;
        this.aktif = true;
        this.recorderService = recorderService;
        this.camera = camera;
    }

    @Override
    public void run() {
        // Tentukan lokasi penyimpanan dan nama file
        File picDir = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES), "MyRecorder");
        if (!picDir.exists()) {
            picDir.mkdir();
        }
        String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
        final String outputFile = picDir.getAbsolutePath() + File.separator +  "REC_" + timeStamp + ".mp4";
        Log.d("MyRecorder", "Menyimpan ke " + outputFile);

        try {
            // Memulai proses rekaman
            MediaRecorder mediaRecorder = new MediaRecorder();
            camera.unlock();
            mediaRecorder.setCamera(camera);
            mediaRecorder.setOrientationHint(270);
            mediaRecorder.setVideoSource(MediaRecorder.VideoSource.CAMERA);
            mediaRecorder.setAudioSource(MediaRecorder.AudioSource.CAMCORDER);
            mediaRecorder.setProfile(CamcorderProfile.get(CamcorderProfile.QUALITY_LOW));
            mediaRecorder.setOutputFile(outputFile);
            mediaRecorder.setPreviewDisplay(recorderService.getDummyPreview().getHolder().getSurface());
            mediaRecorder.prepare();
            mediaRecorder.start();
            aktif = true;
            while (aktif) {
                Thread.sleep(100);
            }
            mediaRecorder.stop();
            mediaRecorder.reset();
            mediaRecorder.release();

            // Publikasikan file
            MediaScannerConnection.scanFile(recorderService, new String[]{outputFile}, new String[]{"video/mp4"}, null);
        } catch (Exception ex) {
            Log.e("MyRecorder", "Terjadi kesalahan saat merekam", ex);
        } finally {
            camera.release();
            recorderService.stopSelf(serviceId);
        }
    }

    public boolean isAktif() {
        return aktif;
    }

    public void setAktif(boolean aktif) {
        this.aktif = aktif;
    }

}

Pada kode program di atas, saya menentukan lokasi penyimpanan file video yang dihasilkan dengan menggunakan Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES). Ini akan menghasilkan lokasi penyimpanan di memori internal, bukan di SD Card! Pada Android 4.4 (KitKat), aplikasi dilarang untuk menulis ke SD Card secara langsung dengan alasan keamanan dan kebersihan setelah uninstall aplikasi.

Untuk memulai perekaman video, saya menggunakan MediaRecorder. Saya harus memanggil method seperti setCamera(), setAudioSource(), setVideoSource(), setProfile(), prepare() dan start() secara berurut sesuai dengan yang dituliskan pada dokumentasi API Android. Saya menggunakan setOrientationHint() untuk mengubah orientasi video karena pada perangkat yang saya pakai, orientasi default untuk kamera depan adalah orientasi mendatar (landscape). Selain itu, pada setAudioSource(), saya menggunakan AudioSource.CAMCORDER untuk menggunakan microphone perekaman video. Perangkat Android modern umumnya dilengkapi dengan 2 microphone: di sisi bawah perangkat untuk percakapan telepon (AudioSource.MIC) dan di sisi atas perangkat untuk percakapan video (AudioSource.CAMCORDER).

Thread ini akan terus merekam video hingga nilai aktif menjadi false. Setelah selesai merekam dan menulis file, saya menggunakan method scanFile() dari MediaScannerConnection untuk memberitahu pada aplikasi lain bahwa ada sebuah file video baru yang dibuat. Dengan demikian, saya dapat langsung memutar file ini pada aplikasi pemutar video bawaan. Bila tidak, file akan muncul di aplikasi pemutar video setelah perangkat di-restart (kecuali pada aplikasi yang langsung mencari file).

Berikutnya, saya perlu membuat sebuah SurfaceView untuk keperluan preview video. Walaupun ingin merekam secara diam-diam, perangkat Android saya akan menolak merekam bila tidak ada preview video. Sebagai contoh, saya membuat file bernama DummyPreview yang isinya seperti berikut ini:

public class DummyPreview extends SurfaceView implements SurfaceHolder.Callback {

    private Camera camera;
    private VideoRecordService videoRecordService;
    private RecorderThread recorderThread;
    private int serviceId;

    public DummyPreview(VideoRecordService videoRecordService, int serviceId) {
        super(videoRecordService);
        this.videoRecordService = videoRecordService;
        this.serviceId = serviceId;
        getHolder().addCallback(this);
    }

    @Override
    public void surfaceCreated(SurfaceHolder holder) {
        try {
            camera = Camera.open(1);
            camera.setPreviewDisplay(holder);
            recorderThread = new RecorderThread(serviceId, videoRecordService, camera);
            recorderThread.start();
        } catch (Exception e) {
            Log.e("MyRecorder", "Terjadi kesalahan saat menampilkan preview...", e);
        }
    }

    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {}

    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {
        if (recorderThread != null) {
            recorderThread.setAktif(false);
        }
    }

    public boolean isAktif() {
        return (recorderThread == null)? false: recorderThread.isAktif();
    }

}

Pada kode program di atas, saya menggunakan Camera.open(1) untuk membuka kamera depan. Setelah itu, saya membuat thread baru (RecorderThread yang sudah saya buat sebelumnya) dan menjalankannya.

Selanjutnya, saya tinggal membuat sebuah service baru. Untuk itu, saya men-klik kanan nama proyek dan memilih menu New, Service, Service. Saya mengisi nama service dengan VideoRecordService dan men-klik tombol Finish untuk selesai. Saya kemudian mengisi kode programnya seperti berikut ini:

public class VideoRecordService extends Service {

    private LocalBinder localBinder = new LocalBinder();
    private DummyPreview dummyPreview;

    public DummyPreview getDummyPreview() {
        return dummyPreview;
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        WindowManager wm = (WindowManager) getSystemService(Context.WINDOW_SERVICE);
        this.dummyPreview = new DummyPreview(this, startId);
        WindowManager.LayoutParams lp = new WindowManager.LayoutParams(1, 1,
            WindowManager.LayoutParams.TYPE_SYSTEM_OVERLAY,
            WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH, PixelFormat.TRANSLUCENT);
        lp.gravity = Gravity.START | Gravity.TOP;
        wm.addView(dummyPreview, lp);
        return START_NOT_STICKY;
    }

    @Override
    public void onDestroy() {
        WindowManager wm = (WindowManager) getSystemService(Context.WINDOW_SERVICE);
        wm.removeViewImmediate(dummyPreview);
    }

    @Override
    public IBinder onBind(Intent intent) {
        return localBinder;
    }

    public class LocalBinder extends Binder {

        public void matikan() {
            stopSelf();
        }

        public boolean isAktif() {
            return (dummyPreview != null) && dummyPreview.isAktif();
        }

    }

}

Pada kode program di atas, saat service diaktifkan, saya membuat sebuah instance DummyPreview berukuran 1 x 1 dan meletakkannya pada sisi kiri atas layar. Ukurannya yang sangat kecil akan membuatnya tidak terlihat oleh pengguna.

Selanjutnya, untuk mengaktifkan service, saya akan mengubah kode program MainActivity menjadi seperti berikut ini:

public class MainActivity extends ActionBarActivity implements View.OnClickListener {

    private Switch sw;
    private VideoRecordService.LocalBinder binder;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        LinearLayout layout = new LinearLayout(this);
        sw = new Switch(this);
        sw.setTextOn("Perekam Aktif");
        sw.setTextOff("Perekam Tidak Aktif");
        sw.setOnClickListener(this);
        sw.setId(View.generateViewId());
        layout.addView(sw);
        setContentView(layout);
    }

    @Override
    protected void onStart() {
        super.onStart();
        if (binder == null) {
            Intent intent = new Intent(this, VideoRecordService.class);
            bindService(intent, serviceConnection, 0);
        }
    }

    @Override
    public void onClick(View v) {
        if (sw.isChecked()) {
            Intent intent = new Intent(this, VideoRecordService.class);
            startService(intent);
            finish();
        } else if (binder != null) {
            binder.matikan();
        }
    }

    @Override
    protected void onStop() {
        super.onStop();
        if (binder != null) {
            unbindService(serviceConnection);
            binder = null;
        }
    }

    private ServiceConnection serviceConnection = new ServiceConnection() {
        @Override
        public void onServiceConnected(ComponentName name, IBinder service) {
            binder = (VideoRecordService.LocalBinder) service;
            sw.setChecked(binder.isAktif());
        }

        @Override
        public void onServiceDisconnected(ComponentName name) {
            binder = null;
        }
    };

}

Activity di atas memiliki UI yang hanya terdiri atas sebuah Switch. Bila pengguna mengaktifkannya, maka activity akan ditutup dan proses perekaman akan dimulai. Untuk mematikan proses perekaman, pengguna dapat kembali membuka activity dan mematikan Switch tersebut. Video dapat segera dilihat setelah Switch dimatikan. Pada perangkat yang saya pakai, satu-satunya indikator proses perekaman dimulai dan diselesaikan adalah adanya bunyi shutter. Bila saya tidak menginginkan suara ini, saya bisa men-silent-kan perangkat Android tersebut.

Membaca Suara Dengan Perangkat Android

Perangkat Android dalam bentuk ponsel biasanya dilengkapi dengan microphone untuk menerima suara selama penggunaan telepon. Android juga menyediakan API untuk berinteraksi dengan microphone, misalnya MediaRecorder untuk merekam suara ke dalam format terkompresi atau AudioRecord untuk membaca data mentah dari microphone secara langsung.

Pada dasarnya suara dihasilkan oleh sumber suara yang membuat molekul di udara yang bergetar. Molekul di udara tidak bergerak, mereka hanya saling meneruskan getaran. Semakin jauh getaran molekul dari posisi semula, semakin nyaring suara yang terdengar. Untuk menerjemahkan data suara dalam bentuk analog (getaran molekul) menjadi digital (angka), AudioRecord menggunakan metode Pulse-code Modulation (PCM). Setiap perubahan posisi molekul dari jarak semula akan direkam dalam bentuk angka (nilainya ditentukan oleh ENCODING_PCM_16BIT atau ENCODING_PCM_8BIT). Angka ini adalah nilai voltase yang dihasilkan oleh microphone akibat getaran suara. Nilai voltase dari microphone diambil secara periodik tergantung pada sampling rate yang dipakai. Berdasarkan informasi dari dokumentasi Android, nilai 44.100 Hz (mengambil 44.100 sample per detik) adalah nilai sampling rate yang dijamin bekerja dengan baik pada semua perangkat.

Sebagai latihan, saya akan membuat sebuah proyek Android sederhana yang menampilkan sample suara yang dibaca ke layar. Untuk itu, saya membuat sebuah proyek baru di Android Studio. Saya kemudian membuat sebuah activity dengan isi kode program seperti berikut ini:

public class MainActivity extends Activity implements View.OnClickListener {

    private TextView output;
    private Switch aSwitch;
    private int captureSize;
    private AudioRecord audioRecord;
    private boolean isRunning = false;

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

        RelativeLayout layout = new RelativeLayout(this);
        aSwitch = new Switch(this);
        aSwitch.setId(View.generateViewId());
        aSwitch.setTextOn("Rekam");
        aSwitch.setOnClickListener(this);
        layout.addView(aSwitch);

        output = new TextView(this);
        RelativeLayout.LayoutParams lp = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT);
        lp.addRule(RelativeLayout.BELOW, aSwitch.getId());
        output.setLayoutParams(lp);
        layout.addView(output);

        setContentView(layout);

        captureSize = AudioRecord.getMinBufferSize(44100, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT);
        audioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC, 44100,
            AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, captureSize);
    }

    @Override
    public void onClick(View v) {
        if (aSwitch.isChecked()) {
            audioRecord.startRecording();
            isRunning = true;
            new CaptureThread().start();
        } else {
            isRunning = false;
            audioRecord.stop();
        }
    }

    class CaptureThread extends Thread {

        @Override
        public void run() {
            final short[] buffer = new short[captureSize];
            while(isRunning) {
                audioRecord.read(buffer, 0, captureSize);
                final StringBuilder text = new StringBuilder();
                for (int i=0; i<captureSize; i++) {
                    text.append(buffer[i]);
                    text.append(' ');
                }
                output.postDelayed(new Runnable() {
                    @Override
                    public void run() {
                        output.setText(text.toString());
                    }
                }, 100);
            }
        }

    }

}

Sebelum menjalankan aplikasi, saya perlu menambahkan permission berikut ini pada AndroidManifest.xml:

<uses-permission android:name="android.permission.RECORD_AUDIO" />

Bila saya menjalankan aplikasi, saya bisa melihat data suara yang dibaca melalui microphone seperti pada gambar berikut ini:

Tampilan Aplikasi

Tampilan Aplikasi

Pada program di atas, sebelum merekam, saya memanggil startRecording() dari AudioRecord. Setelah selesai merekam, saya memanggil stop() dari AudioRecord. Untuk membaca data suara yang direkam, saya memanggil method read() dari AudioRecord. Method ini membutuhkan sebuah array (bertipe byte atau short tergantung apakah memakai encoding 8-bit atau 16-bit). Ukuran array ini tidak boleh kurang dari nilai yang dikembalikan oleh AudioRecord.getMinBufferSize().

Membaca data dalam bentuk angka lumayan membingungkan bukan? Oleh sebab itu, saya akan mencoba untuk menampilkannya dalam bentuk grafis. Saya akan mulai dengan memindahkan thread ke dalam sebuah class tersendiri yang saya beri nama AnimasiThread dengan isi kode program seperti berikut ini:

public class AnimasiThread extends Thread {

    private SurfaceHolder surfaceHolder;
    private int captureSize;
    private AudioRecord audioRecord;
    private boolean running;
    private int width, height;
    private float midLine, scale;
    private final Paint warnaGaris;

    public AnimasiThread(SurfaceHolder surfaceHolder) {
        this.surfaceHolder = surfaceHolder;
        this.running = false;
        captureSize = AudioRecord.getMinBufferSize(44100, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT);
        audioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC, 44100, AudioFormat.CHANNEL_IN_MONO,
            AudioFormat.ENCODING_PCM_16BIT, captureSize);
        warnaGaris = new Paint();
        warnaGaris.setColor(Color.GREEN);
    }

    public void setWidth(int width) {
        this.width = width;
    }

    public void setHeight(int height) {
        this.height = height;
        this.midLine = height / 2;
        this.scale =  height / (float) (2 * Short.MAX_VALUE);
    }

    public void startCapture() {
        audioRecord.startRecording();
        running = true;
        start();
    }

    public void stopCapture() {
        running = false;
        audioRecord.stop();
    }

    @Override
    public void run() {
        final short[] buffer = new short[captureSize];
        while(running) {
            audioRecord.read(buffer, 0, captureSize);
            Canvas c = null;
            Float lastX = null, lastY = null;
            try {
                c = surfaceHolder.lockCanvas();
                c.drawColor(Color.BLACK);
                for (int i = 0; i < captureSize; i++) {
                    float trY = midLine - buffer[i] * scale;
                    if ((lastX != null) && (lastY != null)) {
                        c.drawLine(lastX, lastY, i, trY, warnaGaris);
                    } else {
                        c.drawPoint(i, trY, warnaGaris);
                    }
                    lastX = (float) i;
                    lastY = trY;
                }
            } finally {
                if (c != null) {
                    surfaceHolder.unlockCanvasAndPost(c);
                }
            }
        }
    }

}

Thread di atas akan terus menerus membaca data suara dan menampilkannya dalam bentuk grafis dimana garis tengah layar dari height mewakili nilai 0 (tidak ada suara). Nilai yang berada di atas atau di bawah nilai 0 menunjukkan terdapat suara yang menyebabkan membran microphone bergetar.

Selanjutnya, saya membuat sebuah turunan SurfaceView dengan isi seperti berikut ini:

public class GrafikSuara extends SurfaceView implements SurfaceHolder.Callback {

    private AnimasiThread animasiThread;
    private boolean ready = false;
    private int width, height;

    public GrafikSuara(Context context) {
        super(context);
        getHolder().addCallback(this);
    }

    public void mulai() {
        if (ready) {
            animasiThread = new AnimasiThread(getHolder());
            animasiThread.setWidth(width);
            animasiThread.setHeight(height);
            animasiThread.startCapture();
        }
    }

    public void selesai() {
        if (animasiThread != null) {
            animasiThread.stopCapture();
            animasiThread = null;
        }
    }

    @Override
    public void surfaceCreated(SurfaceHolder holder) {
        ready = true;
    }

    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
        this.width = width;
        this.height = height;
        if (animasiThread != null) {
            animasiThread.setWidth(width);
            animasiThread.setHeight(height);
        }
    }

    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {
        if (animasiThread != null) {
            animasiThread.stopCapture();
        }
    }

}

Sebagai langkah terakhir, saya mengubah isi MainActivity menjadi seperti berikut ini:

public class MainActivity extends Activity implements View.OnClickListener {

    private GrafikSuara output;
    private Switch aSwitch;

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

        RelativeLayout layout = new RelativeLayout(this);
        aSwitch = new Switch(this);
        aSwitch.setId(View.generateViewId());
        aSwitch.setTextOn("Rekam");
        aSwitch.setOnClickListener(this);
        layout.addView(aSwitch);

        output = new GrafikSuara(this);
        RelativeLayout.LayoutParams lp = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT);
        lp.addRule(RelativeLayout.BELOW, aSwitch.getId());
        output.setLayoutParams(lp);
        layout.addView(output);

        setContentView(layout);
    }

    @Override
    protected void onPause() {
        super.onPause();
        output.selesai();
    }

    @Override
    public void onClick(View v) {
        if (aSwitch.isChecked()) {
            output.mulai();
        } else {
            output.selesai();
        }
    }

}

Sekarang, bila saya menjalankan aplikasi, saya akan memperoleh grafis yang terus berubah sesuai suara yang diterima oleh microphone, seperti yang terlihat pada gambar berikut ini:

Tampilan Aplikasi

Tampilan Aplikasi

Menggambar Dengan SurfaceView Di Android

Pada artikel Menggambar Dengan Canvas Di Android, saya menggambar bebas dengan membuat sebuah turunan baru dari View. Teknik tersebut tepat dipakai untuk gambar yang statis. Untuk gambar yang perlu diperbaharui secara cepat dan terus menerus (misalnya pada animasi game), Android SDK menyediakan SurfaceView. Salah satu kelebihan SurfaceView adalah ia dapat di-update secara cepat kapan saja, sementara pada View biasa, pemanggilan invalidate() tidak selalu segera memperbaharui tampilan. Walaupun lebih cepat, SurfaceView lebih rumit dan membutuhkan lebih banyak resource dibandingkan View biasa.

Sebagai latihan, saya akan membuat sebuah proyek baru di Android Studio. Proyek latihan ini akan menampilkan animasi lingkaran kecil yang bergerak dari atas layar ke bawah. Untuk itu, sebagai langkah pertama, saya akan membuat domain class yang mewakili sebuah lingkaran dengan nama Lingkaran.java yang isinya seperti berikut ini:

public class Lingkaran {

    private float x, y;
    private float kecepatan;
    private float ukuran;
    private Paint paint;

    public Lingkaran(int xmax, int ymax) {
        Random random = new Random();
        x = random.nextFloat() * xmax;
        y = random.nextFloat() * ymax;
        kecepatan = 0.1f;
        paint = new Paint();
        paint.setColor(Color.rgb(random.nextInt(255), random.nextInt(255), random.nextInt(255)));
        ukuran = 10.0f;
    }

    public void gerak() {
        y += kecepatan;
        kecepatan += 0.1f;
    }

    public void reset() {
        y = 0;
        kecepatan = 0.1f;
    }

    public void gambar(Canvas c) {
        c.drawCircle(x, y, ukuran, paint);
    }

    public float getX() {
        return x;
    }

    public float getY() {
        return y;
    }

}

Class Lingkaran menyimpan informasi posisi dan kecepatan untuk sebuah lingkaran yang ada di layar. Class ini juga menyediakan method gambar() untuk menggambar dirinya pada sebuah Canvas.

Salah satu hal yang unik pada SurfaceView adalah ia tidak harus selalu diperbaharui pada UI Thread. Saya boleh memanipulasi layar dari sebuah thread yang berbeda melalui SurfaceHolder dari SurfaceView tersebut. Sebagai contoh, saya membuat sebuah class yang mewakili thread baru dengan nama AnimasiThread.java yang isinya seperti berikut ini:

public class AnimasiThread extends Thread {

    private SurfaceHolder surfaceHolder;
    private Context context;
    private boolean running = false;
    private List<Lingkaran> lingkarans;
    private int width, height;
    private final Object lock = new Object();

    public AnimasiThread(SurfaceHolder surfaceHolder, Context context) {
        this.surfaceHolder = surfaceHolder;
        this.context = context;
        lingkarans = new ArrayList<>();
    }

    public boolean isRunning() {
        return running;
    }

    public void setRunning(boolean running) {
        this.running = running;
    }

    public void setWidth(int width) {
        this.width = width;
    }

    public void setHeight(int height) {
        this.height = height;
    }

    public void buatLingkaran() {
        synchronized (lock) {
            lingkarans.clear();
            for (int i = 0; i < 100; i++) {
                lingkarans.add(new Lingkaran(width, height));
            }
        }
    }

    @Override
    public void run() {
        while (running) {
            Canvas c = null;
            try {
                c = surfaceHolder.lockCanvas();
                if (running) {
                    c.drawColor(Color.BLACK);
                    synchronized (lock) {
                        for (Lingkaran lingkaran : lingkarans) {
                            lingkaran.gambar(c);
                            lingkaran.gerak();
                            if (lingkaran.getY() > height) {
                                lingkaran.reset();
                            }
                        }
                    }
                }
            } finally {
                if (c != null) {
                    surfaceHolder.unlockCanvasAndPost(c);
                }
            }
        }
    }

}

Untuk membuat class AnimasiThread, dibutuhkan sebuah SurfaceHolder dan Context yang diperoleh dari SurfaceView. Bagian yang paling penting dari class ini adalah method run(). Saya menggunakan lockCanvas() dari SurfaceHolder untuk memperoleh sebuah Canvas. Setelah selesai menggambar dengan Canvas, saya wajib memanggil unlockCanvasAndPost() dari SurfaceHolder.

Sekarang, saya siap untuk membuat sebuah turunan dari SurfaceHolder yang saya beri nama AnimasiView.java dengan isi seperti berikut ini:

package com.snake.mygame.view;

import android.content.Context;
import android.util.AttributeSet;
import android.view.SurfaceHolder;
import android.view.SurfaceView;

public class AnimasiView extends SurfaceView implements SurfaceHolder.Callback {

    private AnimasiThread animasiThread;

    public AnimasiView(Context context, AttributeSet attrs) {
        super(context, attrs);
        SurfaceHolder holder = getHolder();
        holder.addCallback(this);
    }

    @Override
    public void surfaceCreated(SurfaceHolder holder) {
        animasiThread = new AnimasiThread(holder, getContext());
        animasiThread.setRunning(true);
        animasiThread.start();
    }

    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
        animasiThread.setWidth(width);
        animasiThread.setHeight(height);
        animasiThread.buatLingkaran();
    }

    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {
        animasiThread.setRunning(false);
        try {
            animasiThread.join();
        } catch (InterruptedException e) {}
    }

}

Pada sebuah SurfaceView, saya dapat memanggil getHolder() untuk memperoleh SurfaceHolder yang diasosiasikan dengannya. Selain itu, saya juga mendaftarkan SurfaceHolder.Callback dengan memanggil addCallback() milik SurfaceHolder. Method surfaceCreated() akan dipanggil pada saat SurfaceView sudah selesai dibuat dan saya sudah boleh mulai menggambar. Method surfaceChanged() akan dipanggil bila terdapat perubahan ukuran SurfaceView. Method surfaceDestroyed() akan dipanggil pada saat SurfaceView dihapus (tidak dibutuhkan lagi).

Sebagai langkah terakhir, saya akan membuat sebuah activity yang menampilkan SurfaceView yang telah saya buat:

public class MainActivity extends Activity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(new AnimasiView(this, null));
    }

}

Agar activity ini ditampilkan secara satu layar penuh (full-screen), saya mengubah isi AndroidManifest.xml menjadi seperti berikut ini:

<manifest ...>
    <application
        ...
        android:theme="@android:style/Theme.NoTitleBar.Fullscreen" >
        <activity
            android:name=".MainActivity"
            android:label="@string/app_name" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

Sekarang, bila saya menjalankan aplikasi, saya akan memperoleh hasil seperti pada gambar berikut ini:

Tampilan Pada Saat Aplikasi Dijalankan

Tampilan Pada Saat Aplikasi Dijalankan

Menggambar Dengan Canvas Di Android

Saya sudah terbiasa menggunakan Graphics dan Graphics2D untuk menggambar secara bebas di Java Swing. Lalu, bagaimana dengan menggambar di aplikasi Android? Saya dapat menggunakan Canvas untuk keperluan tersebut. Mirip seperti Graphics di Swing, Canvas di Android juga menyediakan method seperti drawLine(), drawRect(), drawPoint(), drawText(), dan sebagainya untuk menghasilkan gambar.

Sebagai latihan, saya akan mulai dengan membuat sebuah proyek baru di Android Studio. Saya akan menggunakan bahasa Groovy pada latihan ini. Langkah pertama yang saya lakukan adalah membuat sebuah turunan dari View dengan nama PlotterView seperti:

class PlotterView extends View {

    float xmin = -10, xmax = 10, ymin = -10, ymax = 10

    float step

    Paint warnaLatar, warnaGaris

    PlotterView(Context context) {
        super(context)
        warnaLatar = new Paint(color: Color.BLACK)
        warnaGaris = new Paint(color: Color.YELLOW)
    }

    void setWarnaLatar(int warna) {
        warnaLatar.color = warna
        invalidate()
    }

    void setWarnaGaris(int warna) {
        warnaGaris.color = warna
        invalidate()
    }

}

Pada method setWarnaLatar() dan setWarnaGaris(), saya memanggil invalidate() agar View ini digambar ulang setelah terdapat perubahan nilai warna latar dan warga garis. Saya akan membuat sumbu x selalu tetap sementara nilai sumbu y bisa bervariasi tergantung pada ukuran layar. Untuk itu, saya perlu menghitung ulang nilai skala, nilai y minimal dan nilai y maksimal setiap kali terdapat perubahan ukuran komponen. Saya dapat melakukannya dengan men-override method onSizeChanged() seperti pada kode program berikut ini:

class PlotterView extends View {

   ...

   @Override
   protected void onSizeChanged(int w, int h, int oldw, int oldh) {
      step = w / (xmax - xmin)
      int jumlahY = h / step
      ymax = jumlahY / 2
      ymin = -ymax
   }

}

Pekerjaan utama dalam menggambar adalah men-override method onDraw(). Sebagai contoh, saya membuat kode program yang menggambar sumbu x dan sumbu y seperti:

class PlotterView extends View {

   ...

   @Override
   protected void onDraw(Canvas canvas) {
     super.onDraw(canvas)
     if (isInEditMode()) {
       canvas.drawColor(warnaLatar.color)
     } else {
       canvas.drawPaint(warnaLatar)
     }

     // Gambar sumbu
     float x0 = translateX(0)
     float y0 = translateY(0)
     canvas.drawLine(0, y0, getWidth(), y0, warnaGaris)
     for (float x=xmin; x <= xmax; x++) {
        canvas.drawText(x.intValue().toString(), translateX(x), (float) (y0 + 12), warnaGaris)
     }
     canvas.drawLine(x0, 0, x0, getHeight(), warnaGaris)
     for (float y=ymax; y >= ymin; y--) {
        if (y != 0) {
          canvas.drawText(y.intValue().toString(), x0, translateY(y), warnaGaris)
        }
     }
   }

   private float translateX(float x) {
     if (x == 0) {
       x = (xmax < 0)? xmax: ((xmin > 0)? xmin: 0)
     }
     ((x - xmin) * step) - 12
   }

   private float translateY(float y) {
     if (y == 0) {
       y = (ymax < 0)? ymax: ((ymin > 0? ymin: 0))
     }
     if (ymax < 0) {
       return (ymax - y) * step
     } else {
       return (getHeight() - ((y - ymin) * step)) - 12
     }
   }
}

Pada kode program di atas, saya menggunakan drawColor() atau drawPaint() milik Canvas untuk mengisi layar dengan sebuah warna yang sama. Untuk menggambar garis sumbu x dan sumbu y, saya menggunakan drawLine() milik Canvas. Untuk menulis angka koordinat, saya menggunakan drawText() milik Canvas.

Satu hal unik yang tidak saya jumpai di Swing adalah method isInEditMode() milik View yang akan mengembalikan nilai true bila View ini sedang ditampilkan oleh visual editor. Dengan menggunakan method ini, saya bisa menghasilkan gambar yang berbeda (yang lebih ringan atau cepat diproses) pada saat View ditampilkan di layout editor Android Studio secara visual (modus preview).

Agar lebih mudah dipakai, saya ingin View baru ini dapat dikonfigurasi melalui XML. Untuk itu, saya perlu mendeklarasikan atribut apa saja yang dapat diatur oleh penggunanya melalui visual designer. Saya akan mulai dengan men-klik kanan pada folder res dan memilih menu New, Android Resource File. Setelah itu, saya mengisi dialog yang muncul seperti pada gambar berikut ini:

Menambah atribut XML untuk custom view

Menambah atribut XML untuk custom view

Saya kemudian mengubah isi file attrs.xml tersebut menjadi seperti berikut ini:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="PlotterView">
        <attr name="warnaLatar" format="color" />
        <attr name="warnaGaris" format="color" />
    </declare-styleable>
</resources>

Berikutnya, saya membuat sebuah layout baru dengan men-klik kanan pada folder res/layout dan memilih menu New, Layout Resource File. Saya mengisi File name dengan nama layout_main dan Root element dengan RelativeLayout. Untuk menambahkan View yang telah dibuat, saya dapat memilih Custom, CustomView pada Palette:

Menambah custom view melalui visual designer

Menambah custom view melalui visual designer

Saat meletakkan komponen tersebut, Android Studio akan memunculkan dialog yang berisi daftar custom view yang dijumpainya. Saya memilih PlotterView dan men-klik Ok. Sekarang, saya dapat meletakkan View tersebut pada rancangan visual. Sebagai latihan, saya mengubah isi layout_main.xml menjadi seperti berikut ini:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <view
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        class="com.snake.mygraph.PlotterView"
        android:id="@+id/output"
        android:layout_alignParentStart="true"
        android:layout_below="@+id/layout_rumus"
        android:layout_alignParentBottom="true" />

    <LinearLayout
        android:orientation="horizontal"
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:id="@+id/layout_x"
        android:layout_alignParentTop="true"
        android:layout_alignParentStart="true">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textAppearance="?android:attr/textAppearanceMedium"
            android:text="@string/x"
            android:id="@+id/textView3" />

        <EditText
            android:layout_width="80dp"
            android:layout_height="wrap_content"
            android:inputType="number"
            android:ems="10"
            android:id="@+id/xmin"
            android:layout_weight="1" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textAppearance="?android:attr/textAppearanceMedium"
            android:text="@string/sampai"
            android:id="@+id/textView4" />

        <EditText
            android:layout_width="80dp"
            android:layout_height="wrap_content"
            android:inputType="number"
            android:ems="10"
            android:id="@+id/xmax"
            android:layout_weight="1" />
    </LinearLayout>

    <LinearLayout
        android:orientation="horizontal"
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:id="@+id/layout_rumus"
        android:layout_alignParentStart="true"
        android:weightSum="1"
        android:layout_below="@+id/layout_x">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textAppearance="?android:attr/textAppearanceMedium"
            android:text="@string/rumus"
            android:id="@+id/textView5" />

        <EditText
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:inputType="textFilter"
            android:id="@+id/rumus"
            android:layout_weight="1" />

        <Button
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/refresh"
            android:onClick="refresh"
            android:id="@+id/refresh" />

    </LinearLayout>

</RelativeLayout>

Hasil preview layout ini pada Android Studio akan terlihat seperti:

Hasil preview di visual designer

Hasil preview di visual designer

Bila saya melakukan perubahan warnaLatar untuk PlotterView di layout_main.xml seperti pada:

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <view
        ...
        class="com.snake.mygraph.PlotterView"
        app:warnaLatar="@android:color/darker_gray"
        ... />

    ...

</RelativeLayout>

maka saya akan memperoleh preview seperti pada:

Mengubah property melalui atribut XML

Mengubah property melalui atribut XML

Terlihat bahwa saya bisa dengan mudah mengisi property dari PlotterView melalui XML.

Selanjutnya, saya akan menampilkan grafis yang mewakili sebuah persamaan yang ditentukan oleh pengguna. Saya ingin pengguna bisa memasukkan sebuah ekspresi Groovy dimana nilai variabel x mewakili nilai di sumbu x. Bila ini adalah aplikasi Java, saya bisa mengevaluasi ekspresi dalam bentuk string dengan menggunakan GroovyShell. Akan tetapi, saat mencoba menerapkannya pada Android, saya menemukan pesan kesalahan berupa ‘General error during class generation: can’t load this type of class file’.

Mengapa demikian? GroovyShell menghasilkan bytecode (berdasarkan string) yang kemudian dibaca dan dikerjakan oleh JDK. Ini bekerja dengan baik pada Java (misalnya pada saat dijalankan di JDK 7). Akan tetapi Android hanya meminjam bahasa Java, sementara arsitektur internalnya sangat berbeda. Salah satu perbedaannya adalah bytecode tidak bisa langsung dikerjakan. Bytecode perlu diterjemahkan lagi ke dalam format Dex (Dalvik EXecutable format). Perlu diketahui bahwa Dex adalah format yang spesifik untuk Android dan bukan bagian dari Java :) Dengan demikian, untuk menghasilkan class secara dinamis di Android, bytecode yang dihasilkan oleh GroovyShell perlu diubah ke dalam format Dex dengan menggunakan tool seperti dx bawaan Android SDK. Beruntungnya, tool dx dapat dipakai secara programatis dengan menambahkan baris berikut ini pada build.gradle:

dependencies {
    ...
    compile 'com.google.android.tools:dx:1.7'
}

Saya bisa menemukan contoh kode program yang menerjemahkan bytecode dari Groovy menjadi Dex kemudian membacanya kembali sebagai class di https://github.com/melix/grooidshell-example. Saya tinggal men-copy file src/main/java/me/champeau/groovydroid/GrooidShell.java dan src/main/java/groovy/lang/GroodClassLoader.java pada sebuah folder seperti src/main/java/helper di proyek saya. Informasi lebih lanjut mengenai teknik ini dapat dibaca di http://melix.github.io/blog/2014/06/grooid2.html.

Berdasarkan kode program yang ada di method evaluate(), saya menambahkan method parse() pada GrooidShell yang isinya seperti berikut ini:

public class GrooidShell {

  ...

  public Script parse(String scriptText) {
    //
    // Bagian ini akan menghasilkan bytecode Java berdasarkan kode program yang
    // dimasukkan oleh pengguna.
    //
    final Set<String> classNames = new LinkedHashSet<String>();
    final DexFile dexFile = new DexFile(dexOptions);
    CompilerConfiguration config = new CompilerConfiguration();
    config.setBytecodePostprocessor(new BytecodeProcessor() {
      @Override
      public byte[] processBytecode(String s, byte[] bytes) {
        ClassDefItem classDefItem = CfTranslator.translate(s+".class", bytes, cfOptions, dexOptions);
        dexFile.add(classDefItem);
        classNames.add(s);
        return bytes;
      }
    });
    GrooidClassLoader gcl = new GrooidClassLoader(this.classLoader, config);
    try {
      gcl.parseClass(scriptText);
    }  catch (Throwable e) {
      Log.e("GrooidShell","Dynamic loading failed!",e);
    }

    //
    // Bagian ini akan menghasilkan format Dex lalu
    // men-load class yang ada.
    //
    byte[] dalvikBytecode = new byte[0];
    try {
      dalvikBytecode = dexFile.toDex(new OutputStreamWriter(new ByteArrayOutputStream()), false);
    } catch (IOException e) {
      Log.e("GrooidShell", "Unable to convert to Dalvik", e);
    }
    Map<String, Class> classes = defineDynamic(classNames, dalvikBytecode);
    Script script = null;
    for (Class scriptClass : classes.values()) {
      if (Script.class.isAssignableFrom(scriptClass)) {
        try {
          script = (Script) scriptClass.newInstance();
        } catch (InstantiationException e) {
          Log.e("GroovyDroidShell", "Unable to create script",e);
        } catch (IllegalAccessException e) {
          Log.e("GroovyDroidShell", "Unable to create script", e);
        }
        break;
      }
    }
    return script;
  }
}

Setelah itu, saya bisa memakai GrooidShell di PlotterView, misalnya seperti pada contoh berikut ini:

class PlotterView extends View {

  ...

  Script script;

  ...

  void setFormula(String formula) {
    GrooidShell shell = new GrooidShell(context.getDir("dynclasses", 0), this.class.classLoader)
    script = shell.parse(formula)
    invalidate()
  }


  @Override
  protected void onDraw(Canvas canvas) {
    ...
    // Gambar grafis
    if (script) {
      Float prevX = null, prevY = null
      for (float x = xmin; x < xmax; x += 0.1) {
        script.x = x
        float y = script.run()
        float trX = translateX(x), trY = translateY(y)
        if (!prevX && !prevY) {
          canvas.drawPoint(trX, trY, warnaGaris)
        } else {
          canvas.drawLine(prevX, prevY, trX, trY, warnaGaris)
        }
        prevX = trX
        prevY = trY
      }
    }
  }    

  ...

}

Sebagai langkah terakhir, saya akan membuat sebuah activity untuk menjalankan aplikasi dengan nama MainActivity.groovy yang isinya seperti berikut ini:

@CompileStatic
class MainActivity extends Activity {

  @Override
  void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.layout_main)
      if (savedInstanceState) {
        findViewById(R.id.output).invalidate()
      }
  }

  void refresh(View view) {
    PlotterView plotter = (PlotterView) findViewById(R.id.output)
    String strXmin = ((EditText) findViewById(R.id.xmin)).getText().toString()
    String strXmax = ((EditText) findViewById(R.id.xmax)).getText().toString()
    String formula = ((EditText) findViewById(R.id.rumus)).getText()
    if (strXmin.length() > 0) {
      plotter.xmin = strXmin.toFloat()
    }
    if (strXmax.length() > 0) {
      plotter.xmax = strXmax.toFloat()
    }
    if (formula.length() > 0) {
      plotter.formula = formula
    }
  }

}

Sekarang, bila saya menjalankan aplikasi, saya akan memperoleh hasil seperti pada gambar berikut ini:

Tampilan aplikasi saat dijalankan

Tampilan aplikasi saat dijalankan

Bila saya mengubah orientasi layar, saya akan memperoleh hasil seperti pada gambar berikut ini:

Tampilan view setelah perubahan orientasi

Tampilan view setelah perubahan orientasi

Pada kode program saat ini, setiap kali saya mengubah orientasi layar, saya harus menekan tombol untuk kembali menggambar grafis. Agar nilai property dari PlotterView tetap disimpan saat terjadi perubahan orientasi layar, saya akan men-override method onSaveInstanceState() dan onRestoreInstanceState() seperti pada contoh berikut ini:

class PlotterView extends View {

  String formulaFx

  ...

  void setFormula(String formulaFx) {
    this.formulaFx = formulaFx
    ...
  }


  @Override
  protected Parcelable onSaveInstanceState() {
    Parcelable superState = super.onSaveInstanceState()
    SavedState ss = new SavedState(superState)
    ss.xmin = xmin
    ss.xmax = xmax
    ss.ymin = ymin
    ss.ymax = ymax
    ss.formulaFx = formulaFx
    ss
  }

  @Override
  protected void onRestoreInstanceState(Parcelable state) {
    if (!(state instanceof SavedState)) {
      super.onRestoreInstanceState(state)
      return
    }
    SavedState ss = (SavedState) state
    super.onRestoreInstanceState(ss.getSuperState())
    xmin = ss.xmin
    xmax = ss.xmax
    ymin = ss.ymin
    ymax = ss.ymax
    setFormula(ss.formulaFx)
  }

  ...

  public static class SavedState extends BaseSavedState {

    String formulaFx
    float xmin, xmax
    float ymin, ymax

    SavedState(Parcelable superState) {
      super(superState)
    }

    SavedState(Parcel parcel) {
      super(parcel)
      formulaFx = parcel.readString()
      xmin = parcel.readFloat()
      xmax = parcel.readFloat()
      ymin = parcel.readFloat()
      ymax = parcel.readFloat()
    }

    @Override
    public void writeToParcel(Parcel out, int flags) {
      super.writeToParcel(out, flags)
      out.writeString(formulaFx)
      out.writeFloat(xmin)
      out.writeFloat(xmax)
      out.writeFloat(ymin)
      out.writeFloat(ymax)
    }

    public static final Parcelable.Creator<SavedState> CREATOR = new Parcelable.Creator<SavedState>() {
      public SavedState createFromParcel(Parcel p) {
        return new SavedState(p)
      }

      public SavedState[] newArray(int size) {
        return new SavedState[size]
      }
    }

  }

}

Tidak dapat dipungkiri bahwa dibutuhkan kode program yang cukup panjang untuk menyimpan dan membaca kembali state dari View setelah activity dihentikan (misalnya saat orientasi layar diubah) :)

Membuat Animasi Pada View Di Android

Salah satu hal unik di pemograman Android adalah ia dilengkapi dengan banyak class di package android.animation untuk mengimplementasikan animasi secara mudah. Bila programmer tidak ingin membuat kode program secara langsung, Android SDK bahkan memperbolehkan animasi didefinisikan dalam bentuk XML. Walaupun animasi bisa diterapkan pada seluruh komponen UI (seperti Button, EditText, dan sebagainya), contoh kasus yang lebih realistis adalah penggunaan animasi pada perpindahan activity dan fragment.

Sebagai latihan, saya akan menambahkan animasi pada sebuah program penjelajah file yang sudah saya buat sebelumnya. Pada saat pengguna menyentuh sebuah folder, saya membuat fragment baru yang menampilkan isi folder tersebut, seperti pada kode program berikut ini:

@Override
public void onFolderClick(File file) {
    ExplorerFragment explorerFragment = new ExplorerFragment();
    Bundle args = new Bundle();
    args.putString(ExplorerFragment.DATA_LOKASI, file.getAbsolutePath());
    explorerFragment.setArguments(args);
    FragmentTransaction transaction = getFragmentManager().beginTransaction();
    transaction.replace(R.id.container, explorerFragment);
    transaction.addToBackStack(null);
    transaction.commit();
}

Perpindahan fragment (membuka folder baru) akan terlihat seperti pada animasi berikut ini:

Perpindahan fragment tanpa animasi

Perpindahan fragment tanpa animasi

Untuk menambahkan animasi saat beralih dari fragment ke fragment lainnya, saya dapat memanggil method setTransition() milik FragmentTransaction. Method ini menerima animasi standar berupa FragmentTransaction.TRANSIT_NONE, FragmentTransaction.TRANSIT_FRAGMENT_OPEN, dan FragmentTransaction.TRANSIT_FRAGMENT_CLOSE. Sebagai contoh, saya menambahkan baris berikut ini sebelum transaction.commit():

transaction.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN);

Sekarang, saya akan menemukan animasi pada saat peralihan fragment seperti berikut ini:

Perpindahan fragment dengan animasi standar

Perpindahan fragment dengan animasi standar

Perhatikan bahwa saat saya menggunakan tombol Back di perangkat mobile untuk kembali ke fragment sebelumnya, Android juga membuat animasi yang merupakan kebalikan saat membuka sebuah fragment. Hal ini dilakukan secara otomatis tanpa perlu kode program tambahan.

Program penjelajah file yang saya buat juga bisa menampilkan isi sebuah file dalam bentuk sebuah activity baru dengan kode program seperti berikut ini:

Intent intent = new Intent(getActivity(), DisplayTextActivity.class);
intent.putExtra(DisplayTextActivity.DATA_FILE, selectedFile.getAbsolutePath());
FileAdapter fileAdapter = (FileAdapter) filesView.getAdapter();
ArrayList<String> daftarFile = fileAdapter.getDaftarFile();
intent.putStringArrayListExtra(DisplayTextActivity.DATA_DAFTAR_FILE, daftarFile);
intent.putExtra(DisplayTextActivity.DATA_INDEX_FILE, daftarFile.indexOf(selectedFile.getAbsolutePath()));            
startActivity(intent);

Android secara otomatis akan melakukan animasi pada saat perpindahan activity. Walaupun demikian, saya tetap bisa memakai animasi yang berbeda dengan melewatkannya dalam bentuk parameter Bundle seperti pada contoh berikut ini:

View selectedView = filesView.getChildAt(position - filesView.getFirstVisiblePosition());
Bundle bundle = ActivityOptions.makeScaleUpAnimation(filesView, selectedView.getLeft(), selectedView.getTop(), 0, 0).toBundle();
startActivity(intent, bundle);

Sekarang, bila saya membuka activity baru, saya akan menemukan animasi seperti berikut ini:

Animasi pada perpindahan activity

Animasi pada perpindahan activity

Selain memakai animasi yang sudah disediakan, saya juga bisa mendefinisikan sebuah animasi dalam bentuk XML. Sebagai contoh, pada penjelajah file yang saya buat, pengguna bisa melihat isi file sebelumnya atau berikutnya (dalam folder yang sama) dengan menyapu layar ke kiri dan ke kanan. Saya akan mendefinisikan sebuah animasi untuk perpindahan file dengan men-klik kanan folder res dan memilih menu New, Android Resource File. Saya kemudian mengisi dialog yang muncul seperti pada gambar berikut ini:

Mendefinisikan animasi dalam bentuk XML

Mendefinisikan animasi dalam bentuk XML

Saya kemudian mengisi XML yang dihasilkan dengan:

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">

    <objectAnimator
        android:valueFrom="1"
        android:valueTo="0"
        android:propertyName="alpha"
        android:duration="0" />

    <objectAnimator
        android:valueFrom="300"
        android:valueTo="0"
        android:propertyName="translationX"
        android:interpolator="@android:interpolator/accelerate_decelerate"
        android:duration="300" />

    <objectAnimator
        android:valueFrom="0"
        android:valueTo="1"
        android:propertyName="alpha"
        android:duration="300" />

</set>

XML di atas pada dasarnya adalah cara deklaratif untuk membuat object dari class ObjectAnimator (https://developer.android.com/reference/android/animation/ObjectAnimator.html). Pada deklarasi di atas, saya membuat beberapa animator yang akan mengubah nilai property translationX dan alpha milik target. Nilai translationX akan mempengaruhi posisi X dari target. Nilai alpha akan mempengaruhi transparansi target (nilai 0 membuat target tidak terlihat sama sekali). Karena animasi saat ini tidak terikat pada sebuah target tertentu, saya bisa memakai ulang animasi yang sama pada banyak View berbeda.

Saya kemudian membuat XML dengan nama kiri_keluar.xml yang isinya seperti berikut ini:

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">

    <objectAnimator
        android:valueFrom="1"
        android:valueTo="0"
        android:propertyName="scaleX"
        android:interpolator="@android:interpolator/accelerate_quad"
        android:duration="300" />

    <objectAnimator
        android:valueFrom="1"
        android:valueTo="0"
        android:propertyName="scaleY"
        android:interpolator="@android:interpolator/accelerate_quad"
        android:duration="300" />

    <objectAnimator
        android:valueFrom="0"
        android:valueTo="-200"
        android:propertyName="translationX"
        android:interpolator="@android:interpolator/accelerate_quad"
        android:duration="300" />

    <objectAnimator
        android:valueFrom="1"
        android:valueTo="0"
        android:propertyName="alpha"
        android:duration="300" />

</set>

Kali ini, saya melakukan animasi pada property scaleX dan scaleY yang mempengaruhi ukuran dari sebuah target.

Berikutnya, saya mendeklarasikan animasi dengan nama kanan_masuk.xml seperti berikut ini:

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">

    <objectAnimator
        android:valueFrom="1"
        android:valueTo="0"
        android:propertyName="alpha"
        android:duration="0" />

    <objectAnimator
        android:valueFrom="-300"
        android:valueTo="0"
        android:propertyName="translationX"
        android:interpolator="@android:interpolator/accelerate_decelerate"
        android:duration="300" />

    <objectAnimator
        android:valueFrom="0"
        android:valueTo="1"
        android:propertyName="alpha"
        android:duration="300" />

</set>

Saya juga mendeklarasikan animasi dengan nama kanan_keluar.xml yang isinya seperti:

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">

    <objectAnimator
        android:valueFrom="1"
        android:valueTo="0"
        android:propertyName="scaleX"
        android:interpolator="@android:interpolator/accelerate_quad"
        android:duration="300" />

    <objectAnimator
        android:valueFrom="1"
        android:valueTo="0"
        android:propertyName="scaleY"
        android:interpolator="@android:interpolator/accelerate_quad"
        android:duration="300" />

    <objectAnimator
        android:valueFrom="0"
        android:valueTo="200"
        android:propertyName="translationX"
        android:interpolator="@android:interpolator/accelerate_quad"
        android:duration="300" />

    <objectAnimator
        android:valueFrom="1"
        android:valueTo="0"
        android:propertyName="alpha"
        android:duration="300" />

</set>

Sekarang, saya siap untuk memakai mereka pada saat membuat fragment baru. Sebagai contoh, saya mengubah kode program saya menjadi seperti berikut ini:

@Override
public void onNextFile() {
    if ((daftarFile != null) && (index < daftarFile.size() -1)) {
        index++;
        tampilkanFile(new File(daftarFile.get(index)), R.animator.kiri_masuk, R.animator.kiri_keluar);
    }
}

@Override
public void onPreviousFile() {
    if ((daftarFile != null) && (index > 0)) {
        index--;
        tampilkanFile(new File(daftarFile.get(index)), R.animator.kanan_masuk, R.animator.kanan_keluar);
    }
}

private void tampilkanFile(File file, int animasiMasuk, int animasiKeluar) {
    DisplayTextFragment displayTextFragment = new DisplayTextFragment();
    Bundle args = new Bundle();
    args.putString(DisplayTextFragment.DATA_FILE, file.getAbsolutePath());
    displayTextFragment.setArguments(args);
    FragmentTransaction transaction = getFragmentManager().beginTransaction();
    transaction.setCustomAnimations(animasiMasuk, animasiKeluar);
    transaction.replace(R.id.container, displayTextFragment);
    transaction.addToBackStack(null);
    transaction.commit();
}

Bila saya menyapu layar ke kiri dan ke kanan, saya akan memperoleh animasi seperti yang terlihat pada:

Contoh animasi buatan sendiri

Contoh animasi buatan sendiri

Ikuti

Get every new post delivered to your Inbox.

Bergabunglah dengan 43 pengikut lainnya.