Add automatic onBackPressed behavior to FlutterFragment (flutter/engine#25453)

This commit is contained in:
Ari Weiland 2021-04-09 16:18:43 -04:00 committed by GitHub
parent 575d32df63
commit ce085d0129
2 changed files with 171 additions and 2 deletions

View File

@ -12,6 +12,7 @@ import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.activity.OnBackPressedCallback;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
@ -38,8 +39,11 @@ import io.flutter.plugin.platform.PlatformPlugin;
* <li>{@link #onUserLeaveHint()}
* </ol>
*
* Additionally, when starting an {@code Activity} for a result from this {@code Fragment}, be sure
* to invoke {@link Fragment#startActivityForResult(Intent, int)} rather than {@link
* {@link #onBackPressed()} does not need to be called through if the fragment is constructed by one
* of the builders with {@code shouldAutomaticallyHandleOnBackPressed(true)}.
*
* <p>Additionally, when starting an {@code Activity} for a result from this {@code Fragment}, be
* sure to invoke {@link Fragment#startActivityForResult(Intent, int)} rather than {@link
* android.app.Activity#startActivityForResult(Intent, int)}. If the {@code Activity} version of the
* method is invoked then this {@code Fragment} will never receive its {@link
* Fragment#onActivityResult(int, int, Intent)} callback.
@ -120,6 +124,12 @@ public class FlutterFragment extends Fragment
* when this fragment is created and destroyed.
*/
protected static final String ARG_ENABLE_STATE_RESTORATION = "enable_state_restoration";
/**
* True if the fragment should receive {@link #onBackPressed()} events automatically, without
* requiring an explicit activity call through.
*/
protected static final String ARG_SHOULD_AUTOMATICALLY_HANDLE_ON_BACK_PRESSED =
"should_automatically_handle_on_back_pressed";
/**
* Creates a {@code FlutterFragment} with a default configuration.
@ -194,6 +204,7 @@ public class FlutterFragment extends Fragment
private RenderMode renderMode = RenderMode.surface;
private TransparencyMode transparencyMode = TransparencyMode.transparent;
private boolean shouldAttachEngineToActivity = true;
private boolean shouldAutomaticallyHandleOnBackPressed = false;
/**
* Constructs a {@code NewEngineFragmentBuilder} that is configured to construct an instance of
@ -320,6 +331,28 @@ public class FlutterFragment extends Fragment
return this;
}
/**
* Whether or not this {@code FlutterFragment} should automatically receive {@link
* #onBackPressed()} events, rather than requiring an explicit activity call through. Disabled
* by default.
*
* <p>When enabled, the activity will automatically dispatch back-press events to the fragment's
* {@link OnBackPressedCallback}, instead of requiring the activity to manually call {@link
* #onBackPressed()} in client code. If enabled, do <b>not</b> invoke {@link #onBackPressed()}
* manually.
*
* <p>This behavior relies on the implementation of {@link #popSystemNavigator()}. It's not
* recommended to override that method when enabling this attribute, but if you do, you should
* always fall back to calling {@code super.popSystemNavigator()} when not relying on custom
* behavior.
*/
@NonNull
public NewEngineFragmentBuilder shouldAutomaticallyHandleOnBackPressed(
boolean shouldAutomaticallyHandleOnBackPressed) {
this.shouldAutomaticallyHandleOnBackPressed = shouldAutomaticallyHandleOnBackPressed;
return this;
}
/**
* Creates a {@link Bundle} of arguments that are assigned to the new {@code FlutterFragment}.
*
@ -346,6 +379,8 @@ public class FlutterFragment extends Fragment
transparencyMode != null ? transparencyMode.name() : TransparencyMode.transparent.name());
args.putBoolean(ARG_SHOULD_ATTACH_ENGINE_TO_ACTIVITY, shouldAttachEngineToActivity);
args.putBoolean(ARG_DESTROY_ENGINE_WITH_FRAGMENT, true);
args.putBoolean(
ARG_SHOULD_AUTOMATICALLY_HANDLE_ON_BACK_PRESSED, shouldAutomaticallyHandleOnBackPressed);
return args;
}
@ -428,6 +463,7 @@ public class FlutterFragment extends Fragment
private RenderMode renderMode = RenderMode.surface;
private TransparencyMode transparencyMode = TransparencyMode.transparent;
private boolean shouldAttachEngineToActivity = true;
private boolean shouldAutomaticallyHandleOnBackPressed = false;
private CachedEngineFragmentBuilder(@NonNull String engineId) {
this(FlutterFragment.class, engineId);
@ -527,6 +563,28 @@ public class FlutterFragment extends Fragment
return this;
}
/**
* Whether or not this {@code FlutterFragment} should automatically receive {@link
* #onBackPressed()} events, rather than requiring an explicit activity call through. Disabled
* by default.
*
* <p>When enabled, the activity will automatically dispatch back-press events to the fragment's
* {@link OnBackPressedCallback}, instead of requiring the activity to manually call {@link
* #onBackPressed()} in client code. If enabled, do <b>not</b> invoke {@link #onBackPressed()}
* manually.
*
* <p>Enabling this behavior relies on explicit behavior in {@link #popSystemNavigator()}. It's
* not recommended to override that method when enabling this attribute, but if you do, you
* should always fall back to calling {@code super.popSystemNavigator()} when not relying on
* custom behavior.
*/
@NonNull
public CachedEngineFragmentBuilder shouldAutomaticallyHandleOnBackPressed(
boolean shouldAutomaticallyHandleOnBackPressed) {
this.shouldAutomaticallyHandleOnBackPressed = shouldAutomaticallyHandleOnBackPressed;
return this;
}
/**
* Creates a {@link Bundle} of arguments that are assigned to the new {@code FlutterFragment}.
*
@ -546,6 +604,8 @@ public class FlutterFragment extends Fragment
ARG_FLUTTERVIEW_TRANSPARENCY_MODE,
transparencyMode != null ? transparencyMode.name() : TransparencyMode.transparent.name());
args.putBoolean(ARG_SHOULD_ATTACH_ENGINE_TO_ACTIVITY, shouldAttachEngineToActivity);
args.putBoolean(
ARG_SHOULD_AUTOMATICALLY_HANDLE_ON_BACK_PRESSED, shouldAutomaticallyHandleOnBackPressed);
return args;
}
@ -581,6 +641,14 @@ public class FlutterFragment extends Fragment
// implementation for details about why it exists.
@VisibleForTesting /* package */ FlutterActivityAndFragmentDelegate delegate;
private final OnBackPressedCallback onBackPressedCallback =
new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
onBackPressed();
}
};
public FlutterFragment() {
// Ensure that we at least have an empty Bundle of arguments so that we don't
// need to continually check for null arguments before grabbing one.
@ -607,6 +675,9 @@ public class FlutterFragment extends Fragment
super.onAttach(context);
delegate = new FlutterActivityAndFragmentDelegate(this);
delegate.onAttach(context);
if (getArguments().getBoolean(ARG_SHOULD_AUTOMATICALLY_HANDLE_ON_BACK_PRESSED, false)) {
requireActivity().getOnBackPressedDispatcher().addCallback(this, onBackPressedCallback);
}
}
@Override
@ -744,6 +815,9 @@ public class FlutterFragment extends Fragment
/**
* The hardware back button was pressed.
*
* <p>If the fragment uses {@code shouldAutomaticallyHandleOnBackPressed(true)}, this method
* should not be called through. It will be called automatically instead.
*
* <p>See {@link android.app.Activity#onBackPressed()}
*/
@ActivityCallThrough
@ -1117,8 +1191,28 @@ public class FlutterFragment extends Fragment
return true;
}
/**
* {@inheritDoc}
*
* <p>Avoid overriding this method when using {@code
* shouldAutomaticallyHandleOnBackPressed(true)}. If you do, you must always {@code return
* super.popSystemNavigator()} rather than {@code return false}. Otherwise the navigation behavior
* will recurse infinitely between this method and {@link #onBackPressed()}, breaking navigation.
*/
@Override
public boolean popSystemNavigator() {
if (getArguments().getBoolean(ARG_SHOULD_AUTOMATICALLY_HANDLE_ON_BACK_PRESSED, false)) {
FragmentActivity activity = getActivity();
if (activity != null) {
// Unless we disable the callback, the dispatcher call will trigger it. This will then
// trigger the fragment's onBackPressed() implementation, which will call through to the
// dart side and likely call back through to this method, creating an infinite call loop.
onBackPressedCallback.setEnabled(false);
activity.getOnBackPressedDispatcher().onBackPressed();
onBackPressedCallback.setEnabled(true);
return true;
}
}
// Hook for subclass. No-op if returns false.
return false;
}

View File

@ -9,10 +9,19 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import androidx.activity.OnBackPressedCallback;
import androidx.fragment.app.FragmentActivity;
import io.flutter.embedding.engine.FlutterEngine;
import io.flutter.embedding.engine.FlutterEngineCache;
import io.flutter.embedding.engine.FlutterJNI;
import io.flutter.embedding.engine.loader.FlutterLoader;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.annotation.Config;
@Config(manifest = Config.NONE)
@ -114,4 +123,70 @@ public class FlutterFragmentTest {
verify(mockDelegate, times(1)).onDestroyView();
verify(mockDelegate, times(1)).onDetach();
}
@Test
public void itDelegatesOnBackPressedAutomaticallyWhenEnabled() {
// We need to mock FlutterJNI to avoid triggering native code.
FlutterJNI flutterJNI = mock(FlutterJNI.class);
when(flutterJNI.isAttached()).thenReturn(true);
FlutterEngine flutterEngine =
new FlutterEngine(
RuntimeEnvironment.application, new FlutterLoader(), flutterJNI, null, false);
FlutterEngineCache.getInstance().put("my_cached_engine", flutterEngine);
FlutterFragment fragment =
FlutterFragment.withCachedEngine("my_cached_engine")
.shouldAutomaticallyHandleOnBackPressed(true)
.build();
FragmentActivity activity = Robolectric.setupActivity(FragmentActivity.class);
activity
.getSupportFragmentManager()
.beginTransaction()
.add(android.R.id.content, fragment)
.commitNow();
FlutterActivityAndFragmentDelegate mockDelegate =
mock(FlutterActivityAndFragmentDelegate.class);
fragment.setDelegate(mockDelegate);
activity.onBackPressed();
verify(mockDelegate, times(1)).onBackPressed();
}
@Test
public void itHandlesPopSystemNavigationAutomaticallyWhenEnabled() {
// We need to mock FlutterJNI to avoid triggering native code.
FlutterJNI flutterJNI = mock(FlutterJNI.class);
when(flutterJNI.isAttached()).thenReturn(true);
FlutterEngine flutterEngine =
new FlutterEngine(
RuntimeEnvironment.application, new FlutterLoader(), flutterJNI, null, false);
FlutterEngineCache.getInstance().put("my_cached_engine", flutterEngine);
FlutterFragment fragment =
FlutterFragment.withCachedEngine("my_cached_engine")
.shouldAutomaticallyHandleOnBackPressed(true)
.build();
FragmentActivity activity = Robolectric.setupActivity(FragmentActivity.class);
activity
.getSupportFragmentManager()
.beginTransaction()
.add(android.R.id.content, fragment)
.commitNow();
OnBackPressedCallback callback = mock(OnBackPressedCallback.class);
when(callback.isEnabled()).thenReturn(true);
activity.getOnBackPressedDispatcher().addCallback(callback);
FlutterActivityAndFragmentDelegate mockDelegate =
mock(FlutterActivityAndFragmentDelegate.class);
fragment.setDelegate(mockDelegate);
assertTrue(fragment.popSystemNavigator());
verify(mockDelegate, never()).onBackPressed();
verify(callback, times(1)).handleOnBackPressed();
}
}