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)🙂

Perihal Solid Snake
I'm nothing...

One Response to Menggambar Dengan Canvas Di Android

  1. Ping-balik: Menggambar Dengan SurfaceView Di Android | The Solid Snake

Apa komentar Anda?

Please log in using one of these methods to post your comment:

Logo WordPress.com

You are commenting using your WordPress.com account. Logout / Ubah )

Gambar Twitter

You are commenting using your Twitter account. Logout / Ubah )

Foto Facebook

You are commenting using your Facebook account. Logout / Ubah )

Foto Google+

You are commenting using your Google+ account. Logout / Ubah )

Connecting to %s

%d blogger menyukai ini: