[ANDROID] มาเพิ่มลูกเล่นให้ Activity Transition กันดีกว่า


          วันนี้จะมาลองเล่น Activity Transition กันครับ เจ้า Activity Transition ที่ว่าจริงๆ ก็คือ Animation ตอนเปลี่ยนจาก Activity A ไป Activity B นั่นแหละครับ โดยปกติเวลาเปลี่ยน Activity ของ Android นั้นจะมี Animation เป็นแบบแข็งๆ กดปุ๊ปเด้งออกมาปั๊บ แต่สำหรับ Android version 5.0 (API level 21) เป็นต้นไป ทาง Google ได้ปล่อย Material Design ออกมา พร้อมกับ API Activity Transition แบบใหม่ที่จะทำให้ Application ของเราแลดูมีชีวิตชีวามากขึ้น (ปล่อยมาตั้งแต่ปีที่แล้ว พึ่งจะได้มาลองเล่น :P)

ลองมาดูภาพประกอบเพื่อความเข้าใจกันซักหน่อย


          จากภาพประกอบจะเห็นว่า มีการขยายภาพจาก GridView มาแสดงภาพใหญ่ในหน้า Activity ใหม่และหากสังเกตุดีๆ จะเห็นว่ามันจะขยายจากจุดที่เรากดนะครับ cool สุดๆ ไปเลย ซึ่งการทำงานลักษณะนี้จะเรียกว่า Shared Element Activity Transition และวันนี้เราจะมาเขียนโค้ดให้ Application ของเราทำงานแบบนี้กัน
Share Element คืออะไร? ก็ตามชื่อเลยก็คือ View ที่ทั้ง 2 activity มีความสัมพันธ์กันอยู่ ถ้าดูตามรูปตัวอย่างก็จะเป็น Thumbnail ใน GridView และ ImageView ที่ขยายใหญ่ขึ้นมานั่นเอง ส่วนวิธีการจะทำยังไงให้สัมพันธ์กันนั่นก็ลองตามมาดูกันนะครับ :)

#0 ปรับความเข้าใจ

  • มีผลกับ Android version 5.0 (API level 21)  ขึ้นไป
  • มีการใช้การ GridView เพราะฉะนั้นต้องไปดูเรื่อง ViewHolder มาด้วย
  • ใช้ Library Picasso ในการช่วยโหลดรูปภาพ
  • เนื่องจากอยากให้ Application ทำงานได้ปกติบน Android version ต่ำกว่า 5.0 ด้วยจึงมีการใช้ ActivityCompat (v4 Support Libraries) เพื่อช่วยในการจัดการกับ Activity (ถ้าไม่ใช้จะต้องมีการเขียน Condition ในการตรวจสอบ version และ implement โค้ดเอง)

    ** หมายเหตุ ActivityCompat รองรับการทำ Custom Activity Transition ของ Android version 4.1 ขึ้นไปด้วยนะ  แต่ต้องสร้าง animation เอง

#1 มาเริ่มกันที่ Dependency

          ตามที่ได้บอกไปด้านบนว่าเราจะใช้ ActivityCompat ที่มากับ v4 Support Libraries แต่เดี๋ยวก่อน!! การพัฒนา Android Application ยุคนี้ ผมคิดว่านักพัฒนาส่วนใหญ่คงใช้ v7 appcompat library กันหมดแล้ว ซึ่งรวบหัวรวบหาง v4 เข้าไปไว้ให้แล้ว เราก็ไม่จำเป็นต้องเพิ่ม v4 เข้าไปอีก
dependencies {
    compile 'com.android.support:appcompat-v7:23.0.1'
    compile 'com.squareup.picasso:picasso:2.5.2'
}

#2 สร้าง Style สำหรับ Transition กันก่อน

          ก็อย่างที่ได้บอกไปได้บนนะครับว่า ทำงานได้บน Android version 5.0 ขึ้นไป เพราะฉะนั้นก็ต้องไปสร้าง style ที่ values-v21 นะครับ

res/values-v21/styles.xml
<style name="AppTheme" parent="AppTheme.Base">
    <item name="android:windowContentTransitions">true</item>
    <item name="android:windowAllowEnterTransitionOverlap">true</item>
    <item name="android:windowAllowReturnTransitionOverlap">true</item>

    <item name="android:windowSharedElementEnterTransition">@android:transition/move</item>
    <item name="android:windowSharedElementExitTransition">@android:transition/move</item>
</style>
windowSharedElementEnterTransition
     จะกำหนด Transition ของ Shared Element ว่าจะโผล่เข้ามาในจอ(Scene) อย่างไร
windowSharedElementExitTransition
     ก็ตรงกันข้าม คือ ตอนที่ Shared Element จะออกจากจอไปอย่างไร

#3 มาดูเรื่อง Layout XML กันต่อ

ในบทความนี้จะมี layout อยู่ 3 ตัวด้วยกัน
  1. Layout ของ MainActivity ที่เป็น GridView
  2. Layout ของ GridView Item หรือก็คือภาพ Thumbnail ใน GridView นั่นเอง
  3. Layout ของ DetailActivity ภายในจะมี ImageView และ TextView เพื่อแสดงภาพใหญ่และข้อความ
activity_main.xml
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <GridView
        android:id="@+id/gridView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:columnWidth="120dp"
        android:horizontalSpacing="4dp"
        android:numColumns="auto_fit"
        android:stretchMode="columnWidth"
        android:verticalSpacing="4dp"/>

</RelativeLayout>

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

    <ImageView
        android:id="@+id/detail_image"
        android:layout_width="match_parent"
        android:layout_height="400dp"
        android:scaleType="centerCrop"/>

    <TextView
        android:id="@+id/detail_text"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@android:color/background_dark"
        android:padding="8dp"
        android:textAppearance="@style/TextAppearance.AppCompat.Subhead.Inverse"/>

</LinearLayout>

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

    <ImageView
        android:id="@+id/grid_image"
        android:layout_width="match_parent"
        android:layout_height="120dp"
        android:scaleType="centerCrop"/>

    <TextView
        android:id="@+id/grid_text"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@android:color/background_dark"
        android:padding="8dp"
        android:textAppearance="@style/TextAppearance.AppCompat.Subhead.Inverse"/>

</LinearLayout>

#4 สร้าง Item Model

          สำหรับเก็บข้อมูล URL รูปภาพและข้อความที่จะแสดง
public class Item {
    private String url;
    private String text;

    public Item(String url, String text) {
        this.url = url;
        this.text = text;
    }

    public String getUrl() {
        return url;
    }

    public String getText() {
        return text;
    }
}

#5 มาดูหน้าตาของ GridViewAdapter.java กันต่อ

          อย่าลืมใช้ ViewHolder Pattern กันนะครับ ส่วนท่านไหนใช้ RecyclerView ก็ตามสะดวกได้เลย โดยใน GridViewAdapter ของเราจะมีการใช้ Picasso เพื่อช่วยในการแสดงรูปภาพ
public class GridViewAdapter extends BaseAdapter {

    private List<item> mItems;

    public GridViewAdapter(List<item> items) {
        mItems = items;
    }

    .
    .
    .

    @Override
    public View getView(int position, View convertView, ViewGroup viewGroup) {
        Context context = viewGroup.getContext();
        Item item = (Item) getItem(position);

        ViewHolder holder;
        if (convertView == null) {
            convertView = LayoutInflater.from(context).inflate(R.layout.grid_item, viewGroup, false);

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

        Picasso.with(context)
            .load(item.getUrl())
            .into(holder.imageView);

        holder.textView.setText(item.getText());

        return convertView;
    }

    static class ViewHolder {
        public ImageView imageView;
        public TextView  textView;

        public ViewHolder(View view) {
            imageView = (ImageView) view.findViewById(R.id.grid_image);
            textView = (TextView) view.findViewById(R.id.grid_text);
        }
    }

#6 MainActivity.java

          หน้านี้จะเป็นส่วนที่แสดง GridView ซึ่งเราก็ต้องสร้าง GridViewAdapter และใส่ข้อมูลลงไปครับ
public class MainActivity extends AppCompatActivity {

    private GridViewAdapter mAdapter;

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

        List items = buildItemList();
        mAdapter = new GridViewAdapter(items);

        GridView gridView = (GridView) findViewById(R.id.gridView);
        gridView.setAdapter(mAdapter);
    }

    private List buildItemList() {
        List items = new ArrayList<>();
        for (int i = 1; i <= 10; i++) {
            items.add(new Item("http://lorempixel.com/800/600/sports/" + i, "Item " + i));
        }

        return items;
    }
}

แค่นี้เราก็ได้ GridView ที่สวยงามกันแล้ว แต่ว่าเราต้องการจะให้กดที่ Grid Item แล้วเปิด Activity ใหม่นี่หน่า ทำยังไงหละ? ก็ต้องเพิ่ม event onItemClick เข้าไปหนะสิ
// ใส่ต่อจาก gridView.setAdapter(mAdapter) ได้เลยจ้า
gridView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
    @Override
    public void onItemClick(AdapterView adapterView, View view, int position, long l) {
        Item item = (Item) mAdapter.getItem(position);

        View itemImageView = view.findViewById(R.id.grid_image);
        View itemTextView = view.findViewById(R.id.grid_text);
        DetailActivity.launch(MainActivity.this, itemImageView, itemTextView, item);
    }
});

มาถึงตรงนี้จะเห็นว่ามีโค้ดที่อ้างถึง DetailActivity ซึ่งส่วนนี้จะเป็นการ startActivity() ขึ้นมานั่นเอง คำถามที่เกิดขึ้น คือ โค้ดที่สั่ง startActivity() อยู่ที่ไหน เฉลย!อยู่ใน DetailActivity ที่อยู่ในรูปแบบ static method นั่นเอง มาดูหน้าตาของมันกันดีกว่า
public static final String TRANSITION_IMAGE = "transition:image";
public static final String TRANSITION_TEXT  = "transition:text";

public static final String EXTRA_IMAGE = "DetailActivity:image";
public static final String EXTRA_TEXT  = "DetailActivity:text";

public static void launch(Activity activity, View imageView, View textView, Item item) {
    Intent intent = new Intent(activity, DetailActivity.class);
    intent.putExtra(EXTRA_IMAGE, item.getUrl());
    intent.putExtra(EXTRA_TEXT, item.getText());

    Pair<View, String> imageTransitionView = Pair.create(imageView, TRANSITION_IMAGE);
    Pair<View, String> textTransitionView = Pair.create(textView, TRANSITION_TEXT);

    ActivityOptionsCompat options = ActivityOptionsCompat.makeSceneTransitionAnimation(activity, imageTransitionView, textTransitionView);
    ActivityCompat.startActivity(activity, intent, options.toBundle());
}

คุณพระคุณเจ้า!! โค้ดแปลกๆ โผล่มาอีกเพียบเลยแหะ ลองมาดูเป็นส่วนๆ กันดีกว่าครับ :)

# ส่งข้อมูลผ่าน Intent ระหว่าง Activity โดยข้อมูลที่จะส่ง คือ URL ของภาพและข้อความเพื่อแสดงผลนั่นเอง
public static final String EXTRA_IMAGE = "DetailActivity:image";
public static final String EXTRA_TEXT  = "DetailActivity:text";
Intent intent = new Intent(activity, DetailActivity.class);
intent.putExtra(EXTRA_IMAGE, item.getUrl());
intent.putExtra(EXTRA_TEXT, item.getText());

# ในการทำ Shared Element Activity Transition ต้องมีการบอกว่า Element ไหนที่จะเกิด Transition โค้ดดังต่อไปนี้จะเป็นการบอกว่า Element ไหนเป็น shareElement และมี shareElementName อะไร
public static final String TRANSITION_IMAGE = "transition:image";
public static final String TRANSITION_TEXT  = "transition:text";
Pair<View, String> imageTransitionView = Pair.create(imageView, TRANSITION_IMAGE);
Pair<View, String> textTransitionView = Pair.create(textView, TRANSITION_TEXT);

เสร็จแล้วก็เอา shareElement มาสร้าง Activity Transition เป็น ActivityOptionsCompat หลังจากนั้นก็สั่ง startAcitivity() ด้วย ActivityCompat
ActivityOptionsCompat options = ActivityOptionsCompat.makeSceneTransitionAnimation(activity, imageTransitionView, textTransitionView);
ActivityCompat.startActivity(activity, intent, options.toBundle());

#7 DetailActivity.java

          ส่วนของ DetailActivity ใน onCreate() ก็จะรับข้อมูลจาก MainActivity มาแสดงผลและกำหนด Transition Name ให้กับ View ที่เราอยากให้เกิด Transition (สามารถกำหนดผ่าน xml ด้วย tag android:transitionName ก็ได้นะครับ)
Transition Name กับ shareElementName ต้องตรงกันนะครับ
public class DetailActivity extends AppCompatActivity {
  
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_detail);
        ActionBar actionBar = getSupportActionBar();
        if (actionBar != null) {
            getSupportActionBar().setDisplayHomeAsUpEnabled(true);
        }

        ImageView imageView = (ImageView) findViewById(R.id.detail_image);
        TextView textView = (TextView) findViewById(R.id.detail_text);

        // กำหนด Transition Name ให้กับ ImageView และ TextView
        ViewCompat.setTransitionName(imageView, TRANSITION_IMAGE);
        ViewCompat.setTransitionName(textView, TRANSITION_TEXT);

        String url = getIntent().getStringExtra(EXTRA_IMAGE);
        String text = getIntent().getStringExtra(EXTRA_TEXT);

        textView.setText(text);
        Picasso.with(this)
            .load(url)
            .into(imageView);
    }
}

ถึงตรงนี้ เราจะได้ Transition ตอนเปิด DetailActivity แล้วครับ แต่เพื่อความสมบูรณ์แบบเราก็ควรจะมี Transition ตอนกดปุ่ม Back เพื่อปิด DetailActivity กลับไปที่ MainActivity ด้วยใช่ไหมครับ ขากลับนี่ง่ายหน่อยใช้คำสั่งเดียวเลย supportFinishAfterTransition();
@Override
public boolean onOptionsItemSelected(MenuItem item) {
    switch (item.getItemId()) {
        case android.R.id.home:
            supportFinishAfterTransition();
            return true;
    }
    return super.onOptionsItemSelected(item);
}

องค์ประกอบคงครบถ้วนแล้วนะครับ มาลองดูโค้ดแบบเต็มๆ กันหน่อย
public class DetailActivity extends AppCompatActivity {
    public static final String TRANSITION_IMAGE = "transition:image";
    public static final String TRANSITION_TEXT  = "transition:text";

    public static final String EXTRA_IMAGE = "DetailActivity:image";
    public static final String EXTRA_TEXT  = "DetailActivity:text";

    public static void launch(Activity activity, View imageView, View textView, Item item) {
        Intent intent = new Intent(activity, DetailActivity.class);
        intent.putExtra(EXTRA_IMAGE, item.getUrl());
        intent.putExtra(EXTRA_TEXT, item.getText());

        Pair imageTransitionView = Pair.create(imageView, TRANSITION_IMAGE);
        Pair textTransitionView = Pair.create(textView, TRANSITION_TEXT);

        ActivityOptionsCompat options = ActivityOptionsCompat.makeSceneTransitionAnimation(activity, imageTransitionView, textTransitionView);
        ActivityCompat.startActivity(activity, intent, options.toBundle());
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_detail);
        ActionBar actionBar = getSupportActionBar();
        if (actionBar != null) {
            getSupportActionBar().setDisplayHomeAsUpEnabled(true);
        }

        ImageView imageView = (ImageView) findViewById(R.id.detail_image);
        TextView textView = (TextView) findViewById(R.id.detail_text);

        ViewCompat.setTransitionName(imageView, TRANSITION_IMAGE);
        ViewCompat.setTransitionName(textView, TRANSITION_TEXT);

        String url = getIntent().getStringExtra(EXTRA_IMAGE);
        String text = getIntent().getStringExtra(EXTRA_TEXT);

        textView.setText(text);
        Picasso.with(this)
            .load(url)
            .into(imageView);
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        switch (item.getItemId()) {
            // Respond to the action bar's Up/Home button
            case android.R.id.home:
                supportFinishAfterTransition();
                return true;
        }
        return super.onOptionsItemSelected(item);
    }
}

#8 มาลอง Run กันดูดีกว่า

ผลลัพธ์จะออกมาเหมือนรูปด้านบนเลยครับ ไม่แปะรูปละไฟล์ GIF โหลดหนัก เปลืองเน็ต :P
ผมแปะโค้ดไว้บน Github ยังไงก็ลองๆ ไปเล่นกันดู ได้เรื่องยังไงก็ลองมาแชร์กันด้วยนะครับ
หากมีตรงไหนผิดพลาดก็แนะนำเข้ามาเลยนะครับ ขอบคุณครับ

ลิงค์อ้างอิง

codepath/android_guides : Shared-Element-Activity-Transition
antonioleiva.com : Material Design Everywhere
google : Implementing Material Design in Your
google : Customize Activity Transitions
google : ActivityCompat

Teeranai P

Developer ตัวน้อยๆ ที่หลงใหลในโลกของการพัฒนา Software. รักการเขียนโปรแกรมเป็นอันดับ 2 รองลงมาจากการนอน