07 November 2014

Let's Fragment - รู้จักกับ FragmentTransaction สำหรับการแสดง Fragment [ตอนที่ 1]

Updated on

        ในบทความก่อนหน้านี้เจ้าของบล็อกได้พูดถึงการเรียกใช้ Fragment แบบพื้นฐานไปแล้ว แต่ทว่าในการใช้งานจริงผู้ที่หลงเข้ามาอ่านยังต้องทำความเข้าใจเกี่ยวกับการทำงานของ FragmentTransaction กันก่อนนะ

บทความที่เกี่ยวข้อง

        • Fragment Principle - มารู้จักกับ Fragment กันเถอะ~
        • Let's Fragment - เริ่มต้นง่ายๆกับ Fragment แบบพื้นฐาน
        • Let's Fragment - ว่าด้วยเรื่องการสร้าง Fragment จาก Constructor ที่ถูกต้อง
        • Let's Fragment - รู้จักกับ FragmentTransaction สำหรับการแสดง Fragment [ตอนที่ 1]
        • Let's Fragment - รู้จักกับ FragmentTransaction สำหรับการแสดง Fragment [ตอนที่ 2]
        • Let's Fragment - วงจรชัวิตของ Fragment (Fragment Lifecycle)
        • Let's Fragment - มาทำ View Pager กันเถิดพี่น้อง~ [ตอนที่ 1]
        • Let's Fragment - มาทำ View Pager กันเถิดพี่น้อง~ [ตอนที่ 2]


        จากบทความก่อนหน้านี้ จะเห็นว่า Fragment นั้นเป็นคลาสที่ทำหน้าที่ได้คล้ายกับ Activity แต่ว่าเวลาเรียกใช้งานจะเป็นการแปะ Fragment ลงบน ViewGroup ที่ต้องการได้เลย


        ซึ่งตรงนี้อาจจะทำให้ผู้ที่หลงเข้ามาอ่านหลายๆคนเข้าใจผิดว่ามันก็คือ View แต่ในความเป็นจริงนั้น "Fragment ไม่ใช่ View" เพียงแค่ทำงานด้วยการแปะตัวเองอยู่บน ViewGroup เท่านั้น และจะต้องใช้ FragmentTrasaction ในการแปะ Fragment ลงบน ViewGroup ด้วย

รู้จักกับ FragmentTransaction

        FragmentTransaction เป็นคลาสที่มีหน้าที่จัดการกับการแสดงผลของ Fragment โดยเฉพาะ ในการใส่ Fragment เข้าไปใน Layout หรือแม้กระทั่งการเปลี่ยนเป็น Fragment ตัวอื่น หรือลบ Fragment ตัวนั้นๆออก จะต้องทำผ่าน FragmentTransaction เท่านั้นครับ

        ซึ่ง FragmentTransaction นี่แหละที่จะทำให้นักพัฒนาใช้ Fragment ทำงานได้ยืดหยุ่นมากขึ้น สามารถสลับสับเปลี่ยน Fragment ได้ตามใจชอบ และยังมี BackStack เหมือน Activity อีกด้วย (ซึ่ง View และ ViewGroup ไม่สามารถทำ BackStack ได้)

ลองใช้งาน FragmentTransaction กันเถอะ

        FragmentTransaction จะมีขั้นตอนการใช้งานดังนี้

        • สร้าง FragmentTransaction
        • กำหนดคำสั่งที่ต้องการ
        • สั่งให้เริ่มทำงานด้วยคำสั่ง Commit

        ซึ่งจะมีลักษณะแบบนี้

AwesomeFragment fragment = AwesomeFragment.newInstance();

getSupportFragmentManager()
        .beginTransation()
        .add(R.layout.layout_fragment_container, fragment)
        .commit();

        จากตัวอย่างคือสั่งให้เพิ่ม Fragment ที่ต้องการลงใน ViewGroup ที่มี ID เป็น @+id/layout_fragment_container แต่คำสั่งดังกล่าวจะยังไม่ทำงาน จนกว่าจะเรียกใช้คำสั่ง Commit (ซึ่งเจ้าของบล็อกพิมพ์ต่อท้ายไว้แล้ว) เพียงเท่านี้ AwesomeFragment ก็จะถูกแปะลง ViewGroup เรียบร้อยแล้ว

        การเรียกใช้งาน FragmentTransaction เบื้องต้นก็มีเท่านี้แหละครับ

ลองเรียกใช้งาน FragmentTransaction จริงๆกันเถอะ

        เจ้าของบล็อกจะสร้างไฟล์สำหรับทดลองใช้งาน FragmentTransaction ดังนี้

        • MainActivity.java คู่กับ activity_main.xml
        • HomeFragment.java คู่กับ fragment_home.xml

        ซึ่ง Layout ทั้งสองจะสร้างขึ้นมาโดยมีหน้าตาแบบนี้


        และจะได้ Layout XML ออกมาเป็นแบบนี้

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

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"
        android:gravity="center"
        android:orientation="vertical">

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

        <Button
            android:id="@+id/btn_close_fragment"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Close Fragment" />

    </LinearLayout>

    <FrameLayout
        android:id="@+id/layout_fragment_container"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"
        android:background="#d9e1ea" />

</LinearLayout>

fragment_home.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:gravity="center"
    android:orientation="vertical">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="It's me! Home Fragment!"/>

    <Button
        android:id="@+id/btn_close"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Close" />

</LinearLayout>

        จากนั้นก็ขอสร้าง Activity และ Fragment ขึ้นมาอย่างรวดเร็ว

HomeFragment.java
import android.os.Bundle;
import android.support.annotation.Nullable;
import android.support.v4.app.Fragment;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;

public class HomeFragment extends Fragment {
    private Button btnClose;

    public static HomeFragment newInstance() {
        return new HomeFragment();
    }

    @Nullable
    @Override
    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        return inflater.inflate(R.layout.fragment_home, container, false);
    }

    @Override
    public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);
        btnClose = (Button) view.findViewById(R.id.btn_close);
        btnClose.setOnClickListener(onCloseClickListener);
    }

    private View.OnClickListener onCloseClickListener = new View.OnClickListener() {
        @Override
        public void onClick(View v) {

        }
    };
}

MainActivity.java
import android.os.Bundle;
import android.support.v4.app.FragmentActivity;
import android.view.View;
import android.widget.Button;
import android.widget.FrameLayout;

public class MainActivity extends FragmentActivity {
    private Button btnAddFragment;
    private Button btnRemoveFragment;

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

        btnAddFragment = (Button) findViewById(R.id.btn_add_fragment);
        btnRemoveFragment = (Button) findViewById(R.id.btn_remove_fragment);

        btnAddFragment.setOnClickListener(onAddFragmentClickListener);
        btnRemoveFragment.setOnClickListener(onRemoveFragmentClickListener);
    }

    private View.OnClickListener onAddFragmentClickListener = new View.OnClickListener() {
        @Override
        public void onClick(View v) {

        }
    };

    private View.OnClickListener onRemoveFragmentClickListener = new View.OnClickListener() {
        @Override
        public void onClick(View v) {

        }
    };
}

        จะเห็นว่าเจ้าของบล็อกได้เตรียมปุ่มเพื่อทดลองคำสั่งของ FragmentTransaction ไว้ด้วย ซึ่งตอนนี้ขอเริ่มที่คำสั่งแปะ Fragment ลงบน FrameLayout ที่เจ้าของบล็อกได้เตรียมไว้ก่อน

...

private View.OnClickListener onAddFragmentClickListener = new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        // คำสั่งเพิ่ม Fragment ลงบน ViewGroup
        getSupportFragmentManager()
                .beginTransaction()
                .add(R.id.layout_fragment_container, HomeFragment.newInstance())
                .commit();
    }
};

...

        เมื่อลองทดสอบดู เมื่อกดปุ่ม "ADD FRAGMENT" ก็จะเป็นการเพิ่ม Fragment ใน FrameLayout และได้ผลลัพธ์เป็นแบบนี้


        ถ้าอยากจะลบ Fragment ตัวนี้ออกจาก FrameLayout ด้วยการกดปุ่ม "REMOVE FRAGMENT" ล่ะ? ทำแบบนี้ได้มั้ยนะ?

...

private View.OnClickListener onRemoveFragmentClickListener = new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        // คำสั่งลบ Fragment ที่อยู่บน ViewGroup
        getSupportFragmentManager()
                .beginTransaction()
                .remove(HomeFragment.newInstance())
                .commit();
    }
};

...

        คำตอบคือทำแบบนี้ไม่ได้นะ เพราะเป็นการสร้าง HomeFragment ขึ้นมาใหม่ ดังนั้นจึงถือว่า HomeFragment ที่แปะอยู่บน FrameLayout กับ HomeFragment ที่กำหนดในคำสั่ง remove(...) เป็นคนละตัวกัน

        แปลว่าตอนที่สร้าง Fragment ในตอนแรกจะต้องเก็บ Instance ของ Fragment ตัวนั้นไว้ด้วยใช่มั้ย?

MainActivity.java
...

private View.OnClickListener onAddFragmentClickListener = new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        // คำสั่งเพิ่ม Fragment ลงบน ViewGroup
        currentFragment = HomeFragment.newInstance();
        getSupportFragmentManager()
                .beginTransaction()
                .add(R.id.layout_fragment_container, currentFragment)
                .commit();
    }
};

private View.OnClickListener onRemoveFragmentClickListener = new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        // คำสั่งลบ Fragment ที่อยู่บน ViewGroup
        getSupportFragmentManager()
                .beginTransaction()
                .remove(currentFragment)
                .commit();
    }
};

...

        วิธีแบบนี้ก็ถูกต้องครับ แต่ไม่แนะนำให้ทำแบบนี้ เพราะปกติแล้วผู้ที่หลงเข้ามาอ่านไม่จำเป็นต้องเก็บ Instance ของ Fragment ไว้ที่ Global แบบนี้

        คำสั่ง add(...) ของ FragmentTransaction นั้นถูกสร้างขึ้นมาด้วยการ Overload คำสั่งไว้หลายๆแบบ ซึ่งคำสั่งนี้สามารถกำหนด Tag ของ Fragment ตัวนั้นๆได้ด้วย

private static final String TAG_HOME_FRAGMENT = "tag_home_fragment";

...

getSupportFragmentManager()
        .beginTransaction()
        .add(R.id.layout_fragment_container,
                HomeFragment.newInstance(),
                TAG_HOME_FRAGMENT)
        .commit();

        ตอนที่แปะ HomeFragment ลงใน FrameLayout เจ้าของบล็อกได้กำหนด Tag เพิ่มเข้าไปด้วย โดยให้ชื่อ Tag คือ "tag_home_fragment"

        ซึ่งการใส่ Tag จะช่วยให้ค้นหา Fragment ที่แสดงผลอยู่ได้ง่ายขึ้นด้วยคำสั่ง findFragmentByTag(...) (ชื่อคุ้นๆเนอะ) ก็จะได้ผลลัพธ์เป็น Instance ของ Fragment ที่แสดงผลอยู่ ซึ่งเอาไปกำหนดในคำสั่ง remove(...) ต่อได้เลย

private static final String TAG_HOME_FRAGMENT = "tag_home_fragment";

...

Fragment fragment = getSupportFragmentManager().findFragmentByTag(TAG_HOME_FRAGMENT);
if(fragment != null) {
    getSupportFragmentManager()
            .beginTransaction()
            .remove(fragment)
            .commit();
}

        แต่เนื่องจากคำสั่ง findFragmentByTag(...) มีโอกาสเป็น Null ได้ (หา Fragment จาก Tag ดังกล่าวไม่เจอ) ดังนั้นควรใช้ If เผื่อซักหน่อย เพราะถ้าโยน Null เข้าคำสั่ง remove(...) บอกเลยว่า NullPointerException แน่นอน

        จะเห็นว่าคำสั่ง findFragmentByTag(...) ไม่จำเป็นต้องระบุ ViewGroup เพราะคำสั่งดังกล่าวจะค้นหา Fragment ที่มีอยู่ทั้งหมด (FragmentManager จะคอยจำเสมอว่าเคยแปะ Fragment ตัวไหนไว้บ้าง)

        ดังนั้นสรุปคำสั่งที่ถูกต้อง ก็จะได้ออกมาเป็นแบนี้

MainActivity.java
...

private static final String TAG_HOME_FRAGMENT = "tag_home_fragment";

...

private View.OnClickListener onAddFragmentClickListener = new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        // คำสั่งเพิ่ม Fragment ลงบน ViewGroup
        getSupportFragmentManager()
                .beginTransaction()
                .add(R.id.layout_fragment_container,
                        HomeFragment.newInstance(),
                        TAG_HOME_FRAGMENT)
                .commit();
    }
};

private View.OnClickListener onRemoveFragmentClickListener = new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        // คำสั่งลบ Fragment ที่อยู่บน ViewGroup
        Fragment fragment = getSupportFragmentManager().findFragmentByTag(TAG_HOME_FRAGMENT);
        getSupportFragmentManager()
                .beginTransaction()
                .remove(fragment)
                .commit();
    }
};

...

แล้วถ้าอยากสั่งลบจากใน Fragment ตัวนั้นล่ะ?

        ถ้าอยากให้กดปุ่ม "CLOSE" ที่อยู่ใน HomeFragment แล้วให้ HomeFragment ลบตัวเองออกจาก FrameLayout ก็จะใช้คำสั่งที่ง่ายๆแบบนี้

...

private View.OnClickListener onCloseClickListener = new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        getFragmentManager()
                .beginTransaction()
                .remove(HomeFragment.this)
                .commit();
    }
};

...

        นั่นล่ะครับ เรียก HomeFragment.this ได้เลย เพื่อบอก FragmentManager ว่าลบตัวเองทิ้งนะ

ข้อสังเกตเกี่ยวกับ FragmentManager เวลาเรียกใช้ใน Activity กับ Fragment

        จากคำสั่งข้างบนจะเห็นว่าใน MainActivity จะเรียก FragmentManager ผ่านคำสั่ง getSupportFragmentManager(...) ส่วนใน Fragment จะเรียกผ่านคำสั่ง getFragmentManager(...) 

        อ้าว ทำไมกลายเป็นแบบนั้นล่ะ?

        อธิบายง่ายๆได้แบบนี้ครับ

FragmentActivity

        getFragmentManager(...) จะได้ FragmentManager ของ Fragment API (ที่เพิ่มเข้ามาใน API 11)

        getSupportFragmentManager(...) จะได้ FragmentManager ของ Support v4


Fragment ของ Fragment API

        getFragmentManager(...) จะได้ FragmentManager ของ Fragment API

        getSupportFragmentManager(...) ไม่มีคำสั่งนี้


Fragment ของ Support v4

        getFragmentManager(...) จะได้ FragmentManager ของ Support v4

        getSupportFragmentManager(...) ไม่มีคำสั่งนี้

สรุปก็คือ

        Activity จะต้องเลือกเองว่าจะเอา FragmentManager แบบไหน แต่ใน Fragment นั้นจะรู้อยู่แล้วว่าเป็น Fragment ของ Fragment API หรือ Support v4 ดังนั้นจึงไม่จำเป็นต้องมีคำสั่ง getSupportFragmentManager(...) นั่นเอง

ข้อสังเกตเกี่ยวกับ Fragment เมื่อเกิด Configuration Change

        จากคำสั่งที่ผ่านมา หลังจากที่เพิ่ม Fragment ลงบน FrameLayout แล้ว เมื่อลองหมุนหน้าจอเพื่อเปลี่ยนทิศทางดู (ทำให้เกิด Configuration Change) จะพบว่าหลังจากหมุนหน้าจอแล้ว Fragment ตัวนั้นก็ยังคงอยู่ใน FrameLayout เหมือนเดิม


        ซึ่งต่างจาก View ที่จะหายไปทุกครั้งถ้าใช้คำสั่ง addView(...) เพราะว่า Fragment นั้นมี Life Cycle คล้ายกับ Activity ที่สามารถ Save/Restore Instance State เมื่อถูก Recreate ได้ ดังนั้นตัวมันเองจึงสามารถทำงานหลังจาก Configuration Change ต่อได้ทันที

        อย่างที่บอกไปในตอนแรกว่า Fragment != View นะจ๊ะ แต่พิเศษกว่านั้นเยอะ

        ดังนั้นถ้าจะให้ Fragment แปะอยู่บน ViewGroup ตั้งแต่ Activity เริ่มทำงาน สามารถใส่คำสั่งไว้ที่ onCreate(...) ได้เลย แต่ต้องทำงานเฉพาะตอนที่ Activity ถูกสร้างขึ้นมาใหม่จริงๆเท่านั้น ถ้าเป็นการ Recreate จะต้องข้ามคำสั่งนี้ไป

MainActivity.java
public class MainActivity extends FragmentActivity {
    
    ...

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

        if (savedInstanceState == null) {
            getSupportFragmentManager()
                    .beginTransaction()
                    .add(R.id.layout_fragment_container, HomeFragment.newInstance())
                    .commit();
        }
    }

    ...
}

        เพียงเท่านี้ HomeFragment ก็จะถูกแปะใน MainActivity แค่ตอนที่เพิ่งถูกสร้างขึ้นมาครั้งแรกเท่านั้น ถ้ามีการ Recreate ใหม่ก็จะข้ามคำสั่งไปทันที

Fragment ก็มี BackStack เหมือนกันนะ!

        อันนี้ก็บอกไปในตอนแรกแล้วเหมือนกัน แต่คำสั่งที่ผ่านมานั้นไม่ได้พูดถึง BackStack เลยซักนิด เพราะว่าการทำให้ Fragment เก็บไว้ใน BackStack จะต้องเพิ่มคำสั่งเข้าไปอีกหนึ่งคำสั่ง

        นั่นหมายความว่ามาถึงตอนนี้ ถ้าเจ้าของบล็อกกดปุ่ม Back ก็จะเป็นการปิด Activity ตัวนี้ทันที โดยไม่สนว่ามี Fragment อยู่หรือไม่

        ดังนั้นถ้าอยากให้ Fragment ถูกเก็บไว้ใน BackStack ด้วย จะต้องเพิ่มคำสั่งใน FragmentTransaction เข้ามาอีกหนึ่งคำสั่งแบบนี้

getSupportFragmentManager()
        .beginTransaction()
        .add(R.id.layout_fragment_container, HomeFragment.newInstance(), TAG_HOME_FRAGMENT)
        .addToBackStack("back_stack_home_fragment")
        .commit();

        การเพิ่ม Fragment เข้าไปใน BackStack จะต้องใช้คำสั่ง addToBackStack(...) ด้วยทุกครั้ง โดยสามารถกำหนดชื่อของ Stack นั้นๆได้ เผื่อว่าจะใช้ดึง Fragment จาก BackStack ในภายหลัง

        แต่ถ้าไม่ได้ใช้งานอะไรแบบนั้น สามารถกำหนดค่าเป็น Null ได้เลย (ส่วนใหญ่ก็นิยมทำแบบนี้กัน)

getSupportFragmentManager()
        .beginTransaction()
        .add(R.id.layout_fragment_container, HomeFragment.newInstance(), TAG_HOME_FRAGMENT)
        .addToBackStack(null)
        .commit();

BackStack ของ Fragment ทำงานยังไง?

        การทำงานก็จะเหมือนกับของ Activity นั่นแหละ คอยเก็บ Stack ไว้ให้เรื่อยๆเมื่อใช้คำสั่ง addToBackStack(...)


         ซึ่ง BackStack จะช่วยให้สามารถดึง Fragment ตัวเก่าที่เคยแปะไว้ กลับขึ้นมาทำงานต่อจากเดิมได้ทันทีโดยไม่ต้องไปสร้างใหม่ให้วุ่นวาย ซึ่งเบื้องหลังการทำงานของ BackStack ก็คือจะ Recreate Fragment ตัวนั้นๆในภายหลัง เมื่อต้องการดึง Fragment ขึ้นมาจาก BackStack เพื่อให้ทำงานต่อ

         ดังนั้นเมื่อ Fragment ใดๆก็ตามถูกส่งเข้าไปใน BackStack (มี Fragment ตัวใหม่สร้างขึ้นมาแทนที่) ก็จะทำให้ Fragment ตัวนั้นหยุดทำงานและถูกทำลายทิ้ง แต่ยังคงเก็บข้อมูลต่างๆผ่าน Save/Restore Instance State ได้ ซึ่งจะช่วยให้ Fragment สามารถกลับมาทำงานได้ต่อ หลังจากที่ถูก Recreate ขึ้นมาใหม่


        เวลาที่ผู้ใช้กดปุ่ม Back ตอนที่มี Fragment อยู่ใน BackStack ก็จะทำการดึง Fragment ที่อยู่ใน BackStack ออกมาทีละตัวจนหมดก่อน จากนั้นจะค่อยไปทำงานที่ BackStack ของ Activity ต่อ

สรุป

        FragmentTransaction ก็เป็นหนึ่งในพื้นฐานที่ควรรู้เมื่อต้องใช้งาน Fragment เพราะว่า ถ้าใช้งาน FragmentTransaction จนคล่องก็จะทำให้สามารถควบคุมลำดับการแสดงผลของ Fragment ได้ตามต้องการ เพราะในทุกวันนี้การสร้างแอปฯก็ไม่ได้ใช้แค่ Activity เสมอไป ต้องหยิบ Fragment มาใช้อยู่บ่อยๆ

        ถ้าผู้ที่หลงเข้ามาอ่านเข้าใจเรื่อง BackStack ของ Fragment ก็จะทำให้สามารถออกแบบการทำงานของแอปฯได้ซับซ้อนมากขึ้น สามารถกำหนดลำดับการแสดงผลโดยใช้ BackStack ของ Fragment และ Activity ร่วมกัน

        สำหรับเรื่องราวของ FragmentTransaction ยังไม่จบเพียงเท่านี้ แต่ขอหนีไปเขียนต่อในบทความตอนที่ 2 นะครับ ตามไปอ่านกันได้ที่ Let's Fragment - รู้จักกับ FragmentTransaction สำหรับการแสดง Fragment [ตอนที่ 2]