Day 1 : Android Staggered Grid

Day 1 : Android Staggered Grid Cover Image

สวัสดีครับ บทความนี้เป็นบทความแรกเลยนะครับ ที่ผมจะมาเขียน ในซีรีย์ Learn 30 Android Libraries in 30 days ตั้งเป้าไว้ ว่าจะต้องรู้ Library ให้ได้วันละ 1 ตัว รู้สึกตื่นเต้นดี ที่ได้ลองทำแบบนี้ คิดว่ามันยากและท้าทายดี ต้องมาลุ้นกัน ว่าจะทำได้มั้ย ฮ่าๆ

สำหรับวันแรก วันนี้ ผมขอเลือก AndroidStaggeredGrid เป็นตัวแรกเลยละกัน

สำหรับบทความทั้งหมด อ่านได้จากด้านล่างครับ

ทำไมถึงตัดสินใจเลือก AndroidStaggeredGrid?

จริงๆ ก็กำลังตัดสินใจอยู่ ว่าจะเลือกอะไรก่อนหลังดี เพราะไปนั่งไล่ๆดูแล้ว บางอันท่าทางจะใช้เวลาศึกษานาน อันนี้น่าจะไม่ยากมาก เพราะว่าวันนี้เวลามีจำกัด ก็เลยตัดสินใจเลือกเลย ทีแรกเห็นมันมีคล้ายๆกัน อยู่ 2 -3 Library แต่เห็นอันนี้พัฒนาด้วยทีมของ Etsy ก็เลยเลือกมา ส่วนอันอื่น ยังไม่ได้ดูว่ามันต่างกันยังไงบ้าง?

AndroidStaggeredGrid คืออะไร?

AndroidStaggeredGrid มันก็คือ Grid View นั่นแหละ แต่ว่ามันแตกต่างจาก Grid View ธรรมดา ก็ตรงที่ว่า แต่ละ Column มันมีขนาดความสูงไม่เท่ากัน (ปกติ Grid View ความสูง มันจะเท่ากันหมด) หากใครยังงงๆ ลองดูภาพ Pinterest ก็จะเข้าใจ ว่าเป็นยังไง หรือใครเคยทำเว็บโดยใช้พวก Mansonry jQuery ก็น่าจะคุ้นเลย

Pinterest

จุดเด่นของมันยังไม่ได้มีแค่นี้ มันยังสามารถฟิกตำแหน่ง เวลาเปลี่ยนโหมด แนวตั้ง แนวนอนได้ และก็รองรับ การใส่ Footer หรือ Header ได้ด้วย

Features

  • ปรับแต่ง จำนวน Column ได้เอง
  • รองรับการ setHeader() และ setFooter()
  • ปรับแต่ง margin, padding ระหว่าง Column ได้
  • ปรับโหมด แนวตั้ง แนวนอน
  • Supports AbsListView.OnScrollListener (ส่วนนี้ยังไม่ได้ลอง)

Getting Started

ทดสอบ ลองเล่นกับมันดูซักหน่อย เริ่มแรกผมทำการเปิดโค๊ด Sample ดูครับ ว่ามันทำยังไง แล้วก็ดูตัวอย่าง 1 ตัวอย่าง พบว่า เค้าใช้ตัว View ของเค้าเองชื่อว่า StaggeredGridView และการใช้งานทุกๆอย่าง ก็แทบเหมือนกัับการทำ ListView, GridView เลย โดยการ setAdapter() เหมือนกัน ต่างกันที่ ตัว CustomAdapter ของเค้า จะมีการคำนวณความสูงของ row ด้วย ส่วนตัวผมก็พยายามใช้ฟังค์ชันที่มากับตัวอย่าง โดยไม่ได้แก้อะไรมากนะครับ มาลองเริ่มสร้างโปรเจ็คด้วย AndroidStaggeredGrid กันดู

วิธีสร้างโปรเจ็ค ผมไม่พูดถึงนะครับ ข้ามไปตรงส่วนการโหลด Library กันเลย ผมใช้ Andriod Studio ฉะนั้น ผมก็เพิ่ม Library โดยการเพิ่มในไฟล์ build.gradle ดังนี้ ปัจจุบันคือ เวอร์ชัน 1.0.5

apply plugin: 'com.android.application'
...

dependencies {
    compile 'com.etsy.android.grid:library:1.0.5'
    compile 'com.squareup.picasso:picasso:2.3.2'
    ...
}

ผมเพิ่ม Picasso ไปด้วย เพื่อเอาไว้โหลดรูปจาก Internet ครับ

ต่อมา สร้างหน้า Layout สมมติตั้งชื่อว่า activity_main.xml ละกัน นึกชื่อไม่ออก แล้วก็ใช้ View ของเค้า จะได้แบบนี้ (นึกถึง GridView หรือ ListView ครับ คล้ายๆกัน เพียงแค่เปลี่ยนเป็น StaggeredGridView เท่านั้นเอง)

<com.etsy.android.grid.StaggeredGridView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/grid_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:item_margin="8dp"
    app:column_count="3" />

ค่าด้านบน ผมทำการกำหนด จำนวน Column เป็น 3 (หากเราไปกำหนด เวลาเราเปลี่ยนแนวตั้ง แนวนอน มันจะมีจำนวน Column เท่าเดิม ใครอยากเห็นการทำงานเวลาเปลี่ยนแนวตั้ง แนวนอน ก็ไม่ต้องใส่ attribute นี้) ส่วนค่า config อื่นๆ ดูได้จากหน้า Docs ของเค้าเลยนะครับ มีอธิบายไว้หมด

ต่อมา สร้าง Layout สำหรับไอเท็มในแต่ละแถว ผมตั้งชื่อว่า list_item_simple.xml แล้วใช้ DynamicHeightImageView ซึ่งเป็น View ชนิดนึ่ง คล้ายๆ ImageView

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:id="@+id/panel_content"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:descendantFocusability="blocksDescendants">

    <com.etsy.android.grid.util.DynamicHeightImageView
        android:id="@+id/image"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center"/>

</FrameLayout>

แสดงว่า แต่ละแถว ก็จะมีแค่ ImageView แต่จะต่างกันที่ขนาดความสูงเท่านั้น

ต่อมาหน้า MainActivity.java คลาสหลัก ผมทำการประกาศตัวแปร StaggeredGridView, CustomAdapter ซึ่งตัว CustomAdapter ยังไมไ่ด้สร้างนะครับ

อยากที่บอก ให้นึกถึงการทำ Grid View ธรรมดา ก็จะต้องมี Adapter, GridView และ data ที่จะให้มันแสดงในแต่ละแถว MainActivity ก็จะได้หน้าตาแบบนี้

import android.os.Bundle;
import android.support.v7.app.ActionBarActivity;
import com.devahoy.learn30androidlibraries.R;
import com.etsy.android.grid.StaggeredGridView;

import java.util.ArrayList;

public class MainActivity extends ActionBarActivity {

    private StaggeredGridView mGridView;
    private CustomAdapter mAdapter;
    private ArrayList<String> mDataset;

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

        mGridView = (StaggeredGridView) findViewById(R.id.grid_view);
        mAdapter = new CustomAdapter(this, R.id.image);
    }

}

ต่อมา ทำการใส่ข้อมูลแบบ sample ก่อน โดยใช้รูปจาก Dribbble API

@Override
protected void onCreate(Bundle savedInstanceState) {

    ...

    mDataset = generateSampleData();
    for (String data : mDataset) {
        mAdapter.add(data);
    }

    mGridView.setAdapter(mAdapter);

}

private ArrayList<String> generateSampleData() {
    final ArrayList<String> data = new ArrayList<String>();

    data.add("https://d13yacurqjgara.cloudfront.net/users/283599/screenshots/1635215/never_quit.jpg");
    data.add("https://d13yacurqjgara.cloudfront.net/users/46938/screenshots/1635213/datastory-gis-map-icon-set_1x.jpg");
    data.add("https://d13yacurqjgara.cloudfront.net/users/46938/screenshots/1635210/appointment-used-eye-doctor-optometrist-icon-set_1x.jpg");
    data.add("https://d13yacurqjgara.cloudfront.net/users/565942/screenshots/1635208/megacourse-checkout_1x.png");
    data.add("https://d13yacurqjgara.cloudfront.net/users/111758/screenshots/1635207/qwilt_cutting_room_floor_1x.gif");
    data.add("https://d13yacurqjgara.cloudfront.net/users/99875/screenshots/1635187/lonely-goalie_teaser.gif");
    data.add("https://d13yacurqjgara.cloudfront.net/users/46938/screenshots/1635206/unused-eye-doctor-optometrist-icon-set_1x.jpg");
    data.add("https://d13yacurqjgara.cloudfront.net/users/340376/screenshots/1635184/momentum_progress3-02_teaser.jpg");
    data.add("https://d13yacurqjgara.cloudfront.net/users/248947/screenshots/1635205/screen_shot_2014-07-09_at_9.39.23_am_teaser.png");
    data.add("https://d13yacurqjgara.cloudfront.net/users/197415/screenshots/1635204/round-bold-soft-icons_v2short_teaser.jpg");
    data.add("https://d13yacurqjgara.cloudfront.net/users/73845/screenshots/1635203/icons_1x.gif");
    data.add("https://d13yacurqjgara.cloudfront.net/users/46938/screenshots/1635201/bli-custiom-business-learning-icons-set_1x.jpg");
    data.add("https://d13yacurqjgara.cloudfront.net/users/11431/screenshots/1635200/youngblood_01_905_1x.jpg");
    data.add("https://d13yacurqjgara.cloudfront.net/users/406256/screenshots/1635199/micro_1x.jpg");
    data.add("https://d13yacurqjgara.cloudfront.net/users/568675/screenshots/1635198/productshot_teaser.png");
    data.add("https://d13yacurqjgara.cloudfront.net/users/111758/screenshots/1635197/qwilt_comp_1x.jpg");
    data.add("https://d13yacurqjgara.cloudfront.net/users/607527/screenshots/1635195/khal-drogo_x1000_1x.jpg");
    data.add("https://d13yacurqjgara.cloudfront.net/users/607527/screenshots/1635188/dragonfruit_paper_x1000_teaser.jpg");

    return data;
}

ต่อมาสร้างคลาส CustomAdapter.java ขึ้นมา โดยการ extends ArrayAdapter<String> เนื่องจาก ใช้ข้อมูลโดยอ้างอิง path ของรูปภาพ ซึ่งเก็บเป็น String ไว้

package com.devahoy.learn30androidlibraries.day1;

import android.content.Context;
import android.util.SparseArray;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;

import com.devahoy.learn30androidlibraries.R;
import com.etsy.android.grid.util.DynamicHeightImageView;
import com.squareup.picasso.Picasso;

import java.util.Random;

public class CustomAdapter extends ArrayAdapter<String> {

    private LayoutInflater mInflater;
    private Context mContext;

    private static final SparseArray<Double> sPositionHeightRatios =
            new SparseArray<Double>();
    private final Random mRandom;

    static class ViewHolder {
        DynamicHeightImageView imageView;
    }

    public CustomAdapter(final Context context, final int staggeredId) {
        super(context, staggeredId);
        mContext = context;
        mInflater = LayoutInflater.from(context);
        mRandom = new Random();
    }

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        ViewHolder viewHolder;

        if (convertView == null) {
            convertView = mInflater.inflate(R.layout.day1_list_item_simple, parent, false);
            viewHolder = new ViewHolder();
            viewHolder.imageView =
                    (DynamicHeightImageView) convertView.findViewById(R.id.image_view);

            convertView.setTag(viewHolder);
        }
        else {
            viewHolder = (ViewHolder) convertView.getTag();
        }

        double positionHeight = getPositionRatio(position);

        viewHolder.imageView.setHeightRatio(positionHeight);

        String path = getItem(position);
        Picasso.with(mContext)
                .load(path)
                .error(R.drawable.ic_launcher)
                .placeholder(R.drawable.ic_launcher)
                .into(viewHolder.imageView);


        return convertView;
    }

    private double getPositionRatio(final int position) {
        double ratio = sPositionHeightRatios.get(position, 0.0);
        // if not yet done generate and stash the columns height
        // in our real world scenario this will be determined by
        // some match based on the known height and width of the image
        // and maybe a helpful way to get the column height!
        if (ratio == 0) {
            ratio = getRandomHeightRatio();
            sPositionHeightRatios.append(position, ratio);
        }
        return ratio;
    }

    private double getRandomHeightRatio() {
        return (mRandom.nextDouble() / 2.0) + 1.0; // height will be 1.0 - 1.5 the width
    }
}

ด้านบนผมเอา จากตัวอย่างมาแก้ โดยใช้เป็น DynamicHeightImageView แทนที่ DynamicHeightTextView ส่วนตรงเมธอด getView() ก็ธรรมดาเลย ใช้ ViewHolder Pattern และก็เซ็ทค่า ด้วย getItem(position) เมื่อได้ path ของรูปภาพก็โหลดลง ImageView โดยใช้ Picasso มาช่วยครับ

เมื่อลองทดสอบดู ก็พบว่าได้ดังรูป (ไม่มีเวลาหารูปตัวอย่างดีๆ พอดีว่ารูปใน Dribbble ขนาดมันเท่ากันหมด เลยยังไม่เห็นความต่างกับ GridView เท่าไหร่)

Result

สรุป

ตัวนี้ เป็นตัวแรกที่ศึกษาใน ซีรีย์นี้ เนื่องจากเวลาค่อนข้างจำกัด เลยได้แค่ทดลองเล่นไปคร่าวๆ แถมยังไมไ่ด้ลองในส่วน Header Footer เลย แต่จากการใช้ดูแล้ว ก็พบว่า ทำไม่ได้แตกต่างจากการทำ GridView เลย ส่วนตัวคิดว่า ถ้าทำ GridView เป็น ตัวนี้ก็ไม่ได้ยากเลย ส่วนการเรียง Column โดยที่ความสูงไม่เท่ากัน ก็แล้วแต่ความชอบแล้วแหละครับ ว่าชอบหรือไม่ บางทีรูปขนาดไซต์เท่ากัน แต่ว่ามีการยืดขยาย จนทำให้ scale ของภาพเพี้ยนไปบ้างก็มี

โค๊ดผม อัพลง Github ไว้ที่นี่แล้ว

จบไปแล้วครับ สำหรับการเรียนรู้ Library ตัวแรก วันแรกอาจจะยังงงๆ ทำอะไรไม่ถูก เดี่ยวจะพยายามปรับปรุง และหาเวลาเรียนรู้เพิ่มเติมครับถ้ามีโอกาส :D

Chai

Chai Phonbopit : Developer แห่งหนึ่ง • ผู้ชายธรรมดาๆ ที่ชื่นชอบ Node.js, JavaScript และ Open Source มีงานอดิเรกเป็น Acoustic Guitar และ Football

บทความล่าสุด