Dialog เป็นหนึ่งใน Component พื้นฐานที่ Android App แทบทุกตัวจะต้องเรียกใช้งาน และก็เป็นเรื่องพื้นฐานแรกๆที่นักพัฒนาจะได้เรียนรู้ในตอนที่หัดเขียนใหม่ๆ แต่ทว่าทุกวันนี้ผู้ที่หลงเข้ามาอ่านเรียกใช้งาน Dialog ด้วยโค้ดแบบไหนกันอยู่ล่ะ? บทความนี้จะพามาเจาะลึกกับเรื่องพื้นฐานๆของ Dialog กันครับ
การเรียกใช้งาน Dialog นั้นเป็นเรื่องง่าย แต่พอนำไปใช้งานจริงๆก็พบว่าโค้ดที่ใช้กันอยู่บ่อยๆมันใช้งานได้ไม่สมบูรณ์ซักเท่าไร นั่นคือสิ่งที่เจ้าของบล็อกได้เจอและได้มองหาวิธีที่ครอบคลุมที่สุด จนพบว่า "แอปส่วนใหญ่เรียกใช้งานไม่ถูกต้อง" จึงทำให้การทำงานของแอปมีช่องโหว่หรือเกิดบั๊กที่ไม่พึงประสงค์เมื่อเรียกใช้งาน Dialog
แล้วมันไม่ถูกต้องยังไงล่ะ? ลองมาดูทีละขั้นตอนกันครับ
เริ่มจาก Dialog แบบพื้นฐานที่สุด
Dialog พื้นฐานที่ว่าก็คือการเรียกใช้งานจากคลาส Dialog น่ะแหละ โดยเจ้าของบล็อกจะใช้คลาส AlertDialog ด้วยโค้ดง่ายๆแบบนี้AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder.setCancelable(false);
builder.setMessage(R.string.sample_dialog_message);
builder.setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int id) {
// Do something
}
});
builder.setNegativeButton(R.string.cancel, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int id) {
// Do something
}
});
AlertDialog dialog = builder.create();
dialog.show();
ถ้าไม่จำเป็นต้อง Custom Layout ของ Dialog เอง การใช้ AlertDialog เป็นอะไรที่สะดวกดีเพราะอุดมไปด้วยคำสั่งต่างๆที่ทำเตรียมไว้ให้แล้ว โดยสืบทอดมาจากคลาสของ Dialog โดยตรง
แต่ปัญหาหลักๆในการใช้คลาส Dialog ก็คือมันไม่สามารถอยู่รอดปลอดภัยได้เมื่อเกิด Configuration Changes ซึ่งทดสอบได้ง่ายๆด้วยหมุนหน้าจอในระหว่างที่ Dialog กำลังแสดงอยู่
ถ้าเป็น Dialog ที่ไม่ได้ซีเรียสมากนักก็คงไม่มีปัญหาอะไร แต่ถ้าต้องแสดง Dialog ในระหว่าง Process ที่สำคัญแล้วดันถูกปิดหายไปเมื่อหมุนจอก็คงไม่โอเคซักเท่าไรนัก ถึงแม้ว่าจะกำหนด Cancelable เป็น False ไว้ ก็ยังถูกปิดหายไปอยู่ดี
จึงเป็นที่มาว่าคลาส Dialog ไม่ได้ตอบโจทย์อย่างสมบูรณ์ซักเท่าไร ดังนั้นจึงต้องเปลี่ยนมาใช้คลาสที่ชื่อว่า DialogFragment กันครับ
คำแนะนำ : ถ้าจะใช้คลาส AlertDialog แนะนำให้ใช้ของ Support v7 เพื่อให้รองรับการทำงานใหม่ๆโดยที่ยัง Backward Compatible อยู่
เปลี่ยนมาใช้ DialogFragment เพื่อการทำงานที่ครอบคลุมกว่า
เพื่อแก้ปัญหาเรื่อง Configuration Changes จึงมีคลาส DialogFragment เพิ่มเข้ามาให้ใช้งานแทน ซึ่งเป็นคลาสที่สืบทอดต่อมาจาก Fragment อีกที นั่นหมายความว่า DialogFragment ก็คือ Fragment ดีๆนี่เอง จึงทำให้สามารถ DialogFragment ยังคงทำงานต่อได้หลังจาก Configuration Changesแต่การใช้ DialogFragment ก็มีข้อเสียอยู่บ้าง เพราะว่าไม่มีแบบสำเร็จรูปให้ใช้งาน จึงมองว่าเป็นการสร้าง Custom Dialog ไปโดยปริยาย และนั่นก็ทำให้ต้องเขียนโค้ดเยอะกว่าคลาส Dialog เนื่องจากเป็น Fragment แถมยังต้องเขียนโค้ดจัดการกับ Instance State ด้วย และโค้ดจุกจิกอีกพอสมควร
คำแนะนำ : ถ้าจะใช้คลาส DialogFragment แนะนำให้ใช้ของ Support v4 เพื่อให้รองรับการทำงานใหม่ๆโดยที่ยัง Backward Compatible อยู่
ก่อนจะพูดถึงโค้ดจุกจิกที่ว่า ขอยกตัวอย่างด้วยโค้ด DialogFragment เบื้องต้นก่อนดีกว่า
AwesomeDialogFragment.java
import android.os.Bundle;
import android.support.annotation.Nullable;
import android.support.annotation.StringRes;
import android.support.v4.app.DialogFragment;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.TextView;
public class AwesomeDialogFragment extends DialogFragment {
private static final String KEY_MESSAGE = "key_message";
private static final String KEY_POSITIVE = "key_positive";
private static final String KEY_NEGATIVE = "key_negative";
private TextView tvMessage;
private Button btnPositive;
private Button btnNegative;
private int message;
private int positive;
private int negative;
public static AwesomeDialogFragment newInstance(@StringRes int message, @StringRes int positive, @StringRes int negative) {
AwesomeDialogFragment fragment = new AwesomeDialogFragment();
Bundle bundle = new Bundle();
bundle.putInt(KEY_MESSAGE, message);
bundle.putInt(KEY_POSITIVE, positive);
bundle.putInt(KEY_NEGATIVE, negative);
fragment.setArguments(bundle);
return fragment;
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (savedInstanceState == null) {
restoreArguments(getArguments());
} else {
restoreInstanceState(savedInstanceState);
}
}
@Nullable
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.dialog_awesome, container, false);
}
@Override
public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
bindView(view);
setupView();
}
private void bindView(View view) {
tvMessage = (TextView) view.findViewById(R.id.tv_message);
btnPositive = (Button) view.findViewById(R.id.btn_positive);
btnNegative = (Button) view.findViewById(R.id.btn_negative);
}
private void setupView() {
tvMessage.setText(message);
btnPositive.setText(positive);
btnNegative.setText(negative);
}
@Override
public void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putInt(KEY_MESSAGE, message);
outState.putInt(KEY_POSITIVE, positive);
outState.putInt(KEY_NEGATIVE, negative);
}
private void restoreInstanceState(Bundle bundle) {
message = bundle.getInt(KEY_MESSAGE);
positive = bundle.getInt(KEY_POSITIVE);
negative = bundle.getInt(KEY_NEGATIVE);
}
private void restoreArguments(Bundle bundle) {
message = bundle.getInt(KEY_MESSAGE);
positive = bundle.getInt(KEY_POSITIVE);
negative = bundle.getInt(KEY_NEGATIVE);
}
}
ขอตัดส่วนของ Layout XML ออก เพราะไม่ค่อยมีอะไรมากนัก ดูจากโค้ดก็น่าจะพอเดาหน้าตาได้อยู่แล้ว
เมื่อลองทดสอบดูก็จะพบว่าการใช้ DialogFragment จะไม่มีปัญหากับ Configuration Changes แล้ว แลกกับการเขียนโค้ดเพื่อ Save/Restore Instance State เอง
ทีนี้มาพูดถึงเรื่องจุกจิกที่ว่ากันต่อดีกว่า
สร้าง Builder สำหรับ DialogFragment
จากตัวอย่างโค้ดก่อนหน้า เวลาเรียกใช้งานจะต้องใช้คำสั่งแบบนี้String tag = ...
AwesomeDialogFragment fragment = AwesomeDialogFragment.newInstance(R.string.sample_dialog_message, R.string.ok, R.string.cancel);
fragment.show(getSupportFragmentManager(), tag);
การเรียกใช้ DialogFragment จะไม่เหมือนกับ Fragment ทั่วไปตรงที่สามารถใช้คำสั่ง show(...) ได้เลย โดยโยน Fragment Manager และ Tag เข้าไป (ซึ่งข้างในคำสั่งนี้มันก็คือคำสั่งแปะ Fragment ทั่วๆไปน่ะแหละ)
แต่เพื่อทำให้คลาส DialogFragment มีการเรียกใช้งานที่ Seamless กับคลาส Dialog ของเดิม จึงแนะนำให้สร้าง Builder ขึ้นมาแทนที่จะสร้าง Fragment ด้วยคำสั่ง newInstance(...) โดยตรงครับ
AwesomeDialogFragment.java
public class AwesomeDialogFragment extends DialogFragment {
...
public static class Builder {
private int message;
private int positive;
private int negative;
public Builder() {
}
public Builder setMessage(@StringRes int message) {
this.message = message;
return this;
}
public Builder setPosition(@StringRes int positive) {
this.positive = positive;
return this;
}
public Builder setNegative(@StringRes int negative) {
this.negative = negative;
return this;
}
public AwesomeDialogFragment build() {
return AwesomeDialogFragment.newInstance(message, positive, negative);
}
}
}
และเวลาเรียกใช้งานก็จะกลายเป็นแบบนี้
String tag = ...
AwesomeDialogFragment fragment = new AwesomeDialogFragment.Builder()
.setMessage(R.string.sample_dialog_message)
.setPosition(R.string.ok)
.setNegative(R.string.cancel)
.build();
fragment.show(getSupportFragmentManager(), tag);
ทีนี้มาพูดกันต่อกับเรื่องที่ขาดไม่ได้ นั่นก็คือ Event Listener จาก Dialog
เพิ่ม Event Listener ให้ DialogFragment (แบบไม่ถูกต้อง)
เนื่องจาก Dialog จะต้องมี Event Listener อยู่เสมอ เพราะจะต้องมี Button อยู่ในนั้นเป็นอย่างน้อย ซึ่งจากตัวอย่างก่อนหน้านี้ เจ้าของบล็อกยังไม่ได้ผูก Event Listener ให้กับ Button โดยจะเริ่มด้วยวิธีที่ไม่ถูกต้องก่อนซึ่งจะได้ออกมาเป็นแบบนี้
AwesomeDialogFragment.java
public class AwesomeDialogFragment extends DialogFragment {
...
private OnDialogListener onDialogListener;
...
private void setupView() {
tvMessage.setText(message);
btnPositive.setText(positive);
btnNegative.setText(negative);
btnPositive.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (onDialogListener != null) {
onDialogListener.onPositiveButtonClick();
dismiss();
}
}
});
btnNegative.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (onDialogListener != null) {
onDialogListener.onNegativeButtonClick();
dismiss();
}
}
});
}
...
public void setOnDialogListener(OnDialogListener onDialogListener) {
this.onDialogListener = onDialogListener;
}
public interface OnDialogListener {
void onPositiveButtonClick();
void onNegativeButtonClick();
}
public static class Builder {
...
private OnDialogListener onDialogListener;
...
public Builder setOnDialogListener(OnDialogListener listener) {
this.onDialogListener = listener;
return this;
}
public AwesomeDialogFragment build() {
AwesomeDialogFragment fragment = AwesomeDialogFragment.newInstance(message, positive, negative);
fragment.setOnDialogListener(onDialogListener);
return fragment;
}
}
}
เพื่อให้ Event Listener ของ DialogFragment ดูเหมาะสมจึงต้องสร้าง Event Listener ขึ้นมาแทนการใช้ View.OnClickListener (ใช้ได้ แต่ไม่เหมาะสมเพราะต้องมานั่งเช็คว่าเป็น Button อะไรที่ถูกกด) เจ้าของบล็อกจึงต้องสร้าง OnDialogListener ขึ้นมาแล้วให้สามารถกำหนดผ่าน Builder ได้
เนื่องจาก DialogFragment เป็น Fragment จึงต้องสร้างขึ้นมาจาก Default Constructor (ว่าด้วยเรื่องการสร้าง Fragment จาก Constructor ที่ถูกต้อง) ซึ่งจะต้องโยนข้อมูลที่ต้องการไปเก็บไว้ใน Bundle
แต่ทว่าเจ้าของบล็อกไม่สามารถเก็บ OnDialogListener ไว้ใน Bundle ได้นี่สิ... จึงแก้ปัญหาง่ายๆด้วยการสร้าง AwesomeDialogFragment ขึ้นมาแล้วค่อยกำหนดค่า OnDialogListener เข้าไปทีหลัง เท่านี้ก็ใช้ได้แล้ว เย้!!
จากนั้นก็เรียกใช้งานผ่าน Activity/Fragment ได้เลย
String tag = ...
AwesomeDialogFragment fragment = new AwesomeDialogFragment.Builder()
.setMessage(R.string.sample_dialog_message)
.setPosition(R.string.ok)
.setNegative(R.string.cancel)
.setOnDialogListener(onDialogListener)
.build();
fragment.show(getSupportFragmentManager(), tag);
...
AwesomeDialogFragment.OnDialogListener onDialogListener = new AwesomeDialogFragment.OnDialogListener() {
@Override
public void onPositiveButtonClick() {
Toast.makeText(getActivity(), R.string.sample_dialog_confirm, Toast.LENGTH_SHORT).show();
}
@Override
public void onNegativeButtonClick() {
Toast.makeText(getActivity(), R.string.sample_dialog_cancel, Toast.LENGTH_SHORT).show();
}
};
แล้วมันไม่ถูกต้องยังไงหว่า?
เพราะว่าลืม Configuration Changes ไปน่ะสิ เมื่อทดสอบง่ายๆด้วยการหมุนหน้าจอก็จะพบว่าหลังจากหมุนหน้าจอ Event Listener จะไม่ทำงานซะงั้น
อ้าว ทำไมเป็นแบบนั้นล่ะ?
เมื่อเกิด Configuration Changes ขึ้น ก็จะทำให้ DialogFragment ถูกทำลายและสร้างขึ้นมาใหม่ตาม Fragment Lifecycle ซึ่งตอนที่สร้างขึ้นมาใหม่นั้น จะสร้างขึ้นมาจาก Default Constructor จึงทำให้ OnDialogListener ที่เคยกำหนดไว้หายไปโดยปริยาย ซึ่งปัญหานี้ไม่ได้เกิดมาจาก DialogFragment แต่เป็นเรื่องปกติของ Fragment อยู่แล้ว
แล้วจะแก้ปัญหานี้ยังไงล่ะ? มาดูวิธีที่ถูกต้องกันเถอะ
เพิ่ม Event Listener ให้ DialogFragment (แบบถูกต้อง)
เมื่อ DialogFragment ต้องการส่ง Event Listener กลับไปยัง Activity หรือ Fragment ที่เรียกขึ้นมา มันก็คือการสื่อสารข้อมูลระหว่าง Activity กับ Fragment (ถ้า Activity เรียก DialogFragment) หรือ Parent Fragment กับ Child Fragment (ถ้า Fragment เรียก DialogFragment) ดังนั้นคำตอบของปัญหานี้ก็จะย้อนกลับไปเรื่องพื้นฐานของ Fragment ในเรื่อง Communicating with Other Fragmentsดังนั้นรูปแบบที่ถูกต้องจึงเป็นดังนี้
AwesomeDialogFragment.java
public class AwesomeDialogFragment extends DialogFragment {
...
private void setupView() {
tvMessage.setText(message);
btnPositive.setText(positive);
btnNegative.setText(negative);
btnPositive.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
((OnDialogListener) getActivity()).onPositiveButtonClick();
dismiss();
}
});
btnNegative.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
((OnDialogListener) getActivity()).onNegativeButtonClick();
dismiss();
}
});
}
...
public static class Builder {
...
public AwesomeDialogFragment build() {
return AwesomeDialogFragment.newInstance(message, positive, negative);
}
}
}
เมื่อ Activity เรียกใช้งานก็จะต้องประกาศ Implement ไว้แบบนี้
MainActivity.java
public class MainActivity extends AppCompatActivity implements AwesomeDialogFragment.OnDialogListener {
...
private void createDialogFragment() {
String tag = ...
AwesomeDialogFragment fragment = new AwesomeDialogFragment.Builder()
.setMessage(R.string.sample_dialog_message)
.setPosition(R.string.ok)
.setNegative(R.string.cancel)
.build();
fragment.show(getSupportFragmentManager(), tag);
}
@Override
public void onPositiveButtonClick() {
Toast.makeText(getActivity(), R.string.sample_dialog_confirm, Toast.LENGTH_SHORT).show();
}
@Override
public void onNegativeButtonClick() {
Toast.makeText(getActivity(), R.string.sample_dialog_cancel, Toast.LENGTH_SHORT).show();
}
}
เพียงเท่านี้ก็จะทำให้ Event Listener สามารถทำงานต่อได้ ถึงแม้ว่าจะเกิด Configuration Changes ก็ตาม ซึ่งเป็นไปตาม Guideline ที่ทีมแอนดรอยด์ออกแบบไว้
แต่เท่านั้นยังไม่พอ ในตัวอย่างนี้เป็นการเรียกใช้งาน DialogFragment ใน Activity, แล้วถ้าเรียกใช้งานจาก Fragment ล่ะ?
แน่นอนว่าแอปน่าจะพังแน่นอนเนื่องจากเขียน getActivity() ไว้แบบไม่ป้องกันอะไร ดังนั้นมาเขียนให้ดีกว่านี้กันเถอะ!!
ทำ DialogFragment ให้รองรับการเรียกใช้งานจาก Activity และ Fragment
ตอนที่ DialogFragment จะส่ง Event Listener จะต้องมีการเพิ่มโค้ดเข้าไปเพื่อเช็คว่า DialogFragment ถูกเรียกจาก Fragment หรือ Activity แล้วค่อยส่ง Event Listener กลับไปยังต้นทางที่เรียกใช้งานซะAwesomeDialogFragment.java
public class AwesomeDialogFragment extends DialogFragment {
...
private void setupView() {
...
btnPositive.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
OnDialogListener listener = getOnDialogListener();
if (listener != null) {
listener.onPositiveButtonClick();
}
dismiss();
}
});
btnNegative.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
OnDialogListener listener = getOnDialogListener();
if (listener != null) {
listener.onNegativeButtonClick();
}
dismiss();
}
});
}
private OnDialogListener getOnDialogListener() {
Fragment fragment = getParentFragment();
try {
if (fragment != null) {
return (OnDialogListener) fragment;
} else {
return (OnDialogListener) getActivity();
}
} catch (ClassCastException ignored) {
}
return null;
}
...
}
การเช็คว่าถูกเรียกจาก Activity หรือ Fragment ให้ดูที่คำสั่ง getOnDialogListener() โดยคำสั่งจะมีการป้องกัน ClassCastException ด้วย เผื่อว่าลืม Implement OnDialogListener ไว้ที่ Activity/Fragment ต้นทาง
ซึ่งคำสั่งนี้จะทำให้การเรียก DialogFragment บน Activity กับ Fragment นั้นแตกต่างกันออกไป โดยต่างกันตรงที่คลาส Fragment Manager ที่กำหนดให้ DialogFragment
String tag = ...
AwesomeDialogFragment fragment = new AwesomeDialogFragment.Builder()
.setMessage(R.string.sample_dialog_message)
.setPosition(R.string.ok)
.setNegative(R.string.cancel)
.build();
// เรียกใช้งานใน Activity
fragment.show(getSupportFragmentManager(), tag);
// เรียกใช้งานใน Fragment
fragment.show(getChildFragmentManager(), tag);
เพียงเท่านี้ DialogFragment ตัวนี้ก็เสร็จสมบูรณ์และพร้อมใช้งานแล้ว
DialogFragment กับ BackStack ของ Fragment
เนื่องจาก DialogFragment สร้างมาจาก Fragment ดังนั้นจึงสามารถเก็บไว้ใน BackStack ได้เหมือน Fragment ทั่วๆไป แต่เนื่องจากคำสั่ง show(...) ของ DialogFragment ไม่มีคำสั่งเก็บตัวเองไว้ใน BackStackถ้าต้องการให้ DialogFragment เก็บไว้ใน BackStack เพื่อให้กดปุ่ม Back แล้ว BackStack ทำงานได้ถูกต้องตามปกติ ก็จะต้องเปลี่ยนคำสั่งตอนเรียกใช้งานนิดหน่อย
// สามารถใช้ค่า Tag ตัวเดียวร่วมกันได้
String fragmentTag = ...
String backStackTag = ...
// เรียกใช้งานใน Activity
FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction();
fragmentTransaction.addToBackStack(backStackTag);
dialogFragment.show(fragmentTransaction, fragmentTag);
// เรียกใช้งานใน Fragment
FragmentTransaction fragmentTransaction = getChildFragmentManager().beginTransaction();
fragmentTransaction.addToBackStack(backStackTag);
dialogFragment.show(fragmentTransaction, fragmentTag);
ในคำสั่ง show(...) นอกจากจะโยน FragmentManager เข้าไปได้แล้ว ยังสามารถโยน FragmentTransaction เข้าไปได้ด้วยนะเออ จึงทำให้เจ้าของบล็อกสามารถใช้คำสั่งสำหรับ BackStack ได้ตามใจชอบผ่าน FragmentTransaction นั่นเอง
เสร็จสมบูรณ์แล้ว เย้ๆ
สรุป
เนื่องจาก Dialog ไม่ตอบโจทย์ในเรื่อง Configuration Changes จึงทำให้การใช้ DialogFragment เป็นวิธีที่เหมาะสมกว่า แต่ก็ต้องแลกด้วยการเขียนโค้ดเยอะกว่าเดิม รวมไปถึง Save/Restore Instance State ด้วย และเนื่องจากเปลี่ยนไปใช้ DialogFragment จึงทำให้รูปแบบของ Event Listener เปลี่ยนไปด้วย เพื่อให้ DialogFragment ส่ง Event ไปให้ Activity/Fragment ด้วยวิธีที่ถูกต้องสุดท้าย การจะเลือกใช้ Dialog หรือ DialogFragment นั้นขึ้นอยู่กับความต้องการของผู้ที่หลงเข้ามาอ่านอยู่ดี เพราะแต่ละแอปอาจจะต้องการให้ Dialog ทำงานไม่เหมือนกัน แต่ถ้ายังไม่รู้ว่า Dialog ในแอปของตัวเองทำงานถูกต้องจริงๆหรือป่าว ก็แนะนำให้ทดสอบตามที่เจ้าของบล็อกแสดงให้ดูในบทความนี้ครับ
เนื่องจากโค้ดในบทความนี้ค่อนข้างเยอะและวุ่นวายนิดหน่อย จึงแนะนำให้ผู้ที่หลงเข้ามาอ่านเข้าไปดูโค้ดกันแบบเต็มๆได้ที่ Android - Dialog Experiment [GitHub] ซึ่งในนี้จะมีตัวอย่างการเรียกใช้ DialogFragment ใน Activity, Fragment และ Nested Fragment ให้เรียบร้อยแล้ว