Skip to content

Commit 08e23bc

Browse files
authored
feat: overhaul and streamline Android page navigation transitions (#7925)
* feat: remove Animators and replace with Transitions * fix: handle disappearing nested fragments for tabs. Extract TabFragmentImplementation in tab-navigation base for both tabs and bottom navigation * chore: bump webpack cycles counter * feat(android-widgets): add androidx.transition:transition as dependency * chore: fix typescript errors * fix(frame-android): child already has a parent. Replace removeView with removeAllViews * fix(tests): wait for fragment before isAdded() check * fix(bottom-navigation): prevent changeTab logic when fragment manager is destroyed * chore: apply PR comments changes
1 parent dc65402 commit 08e23bc

File tree

20 files changed

+829
-569
lines changed

20 files changed

+829
-569
lines changed

tests/app/ui/tab-view/tab-view-root-tests.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ function waitUntilTabViewReady(page: Page, action: Function) {
2222
action();
2323

2424
if (isAndroid) {
25-
TKUnit.waitUntilReady(() => page.frame._currentEntry.fragment.isAdded());
25+
TKUnit.waitUntilReady(() => page.frame._currentEntry.fragment && page.frame._currentEntry.fragment.isAdded());
2626
} else {
2727
TKUnit.waitUntilReady(() => page.isLoaded);
2828
}

tests/webpack.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const { NativeScriptWorkerPlugin } = require("nativescript-worker-loader/NativeS
1212
const TerserPlugin = require("terser-webpack-plugin");
1313
const hashSalt = Date.now().toString();
1414

15-
const ANDROID_MAX_CYCLES = 68;
15+
const ANDROID_MAX_CYCLES = 66;
1616
const IOS_MAX_CYCLES = 39;
1717
let numCyclesDetected = 0;
1818

tns-core-modules-widgets/android/widgets/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ dependencies {
7777
def androidxVersion = computeAndroidXVersion()
7878
implementation 'androidx.viewpager:viewpager:' + androidxVersion
7979
implementation 'androidx.fragment:fragment:' + androidxVersion
80+
implementation 'androidx.transition:transition:' + androidxVersion
8081
} else {
8182
println 'Using support library'
8283
implementation 'com.android.support:support-v4:' + computeSupportVersion()
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package org.nativescript.widgets;
2+
3+
import android.animation.Animator;
4+
import android.animation.AnimatorListenerAdapter;
5+
import android.animation.AnimatorSet;
6+
import android.view.View;
7+
import android.view.ViewGroup;
8+
import android.view.animation.Interpolator;
9+
10+
import androidx.annotation.NonNull;
11+
import androidx.annotation.Nullable;
12+
import androidx.core.view.ViewCompat;
13+
import androidx.transition.Transition;
14+
import androidx.transition.TransitionListenerAdapter;
15+
import androidx.transition.TransitionValues;
16+
import androidx.transition.Visibility;
17+
18+
import java.util.ArrayList;
19+
20+
public class CustomTransition extends Visibility {
21+
private boolean resetOnTransitionEnd;
22+
private AnimatorSet animatorSet;
23+
private AnimatorSet immediateAnimatorSet;
24+
private String transitionName;
25+
26+
public CustomTransition(AnimatorSet animatorSet, String transitionName) {
27+
this.animatorSet = animatorSet;
28+
this.transitionName = transitionName;
29+
}
30+
31+
@Nullable
32+
@Override
33+
public Animator onAppear(@NonNull ViewGroup sceneRoot, @NonNull final View view, @Nullable TransitionValues startValues,
34+
@Nullable TransitionValues endValues) {
35+
if (endValues == null || view == null || this.animatorSet == null) {
36+
return null;
37+
}
38+
39+
return this.setAnimatorsTarget(this.animatorSet, view);
40+
}
41+
42+
@Override
43+
public Animator onDisappear(@NonNull ViewGroup sceneRoot, @NonNull final View view, @Nullable TransitionValues startValues,
44+
@Nullable TransitionValues endValues) {
45+
if (startValues == null || view == null || this.animatorSet == null) {
46+
return null;
47+
}
48+
49+
return this.setAnimatorsTarget(this.animatorSet, view);
50+
}
51+
52+
public void setResetOnTransitionEnd(boolean resetOnTransitionEnd) {
53+
this.resetOnTransitionEnd = resetOnTransitionEnd;
54+
}
55+
56+
public String getTransitionName(){
57+
return this.transitionName;
58+
}
59+
60+
private Animator setAnimatorsTarget(AnimatorSet animatorSet, final View view) {
61+
ArrayList<Animator> animatorsList = animatorSet.getChildAnimations();
62+
boolean resetOnTransitionEnd = this.resetOnTransitionEnd;
63+
64+
for (int i = 0; i < animatorsList.size(); i++) {
65+
animatorsList.get(i).setTarget(view);
66+
}
67+
68+
// Reset animation to its initial state to prevent mirrorered effect
69+
if (this.resetOnTransitionEnd) {
70+
this.immediateAnimatorSet = this.animatorSet.clone();
71+
}
72+
73+
// Switching to hardware layer during transition to improve animation performance
74+
CustomAnimatorListener listener = new CustomAnimatorListener(view);
75+
animatorSet.addListener(listener);
76+
this.addListener(new CustomTransitionListenerAdapter(this));
77+
78+
return this.animatorSet;
79+
}
80+
81+
private class ReverseInterpolator implements Interpolator {
82+
@Override
83+
public float getInterpolation(float paramFloat) {
84+
return Math.abs(paramFloat - 1f);
85+
}
86+
}
87+
88+
private class CustomTransitionListenerAdapter extends TransitionListenerAdapter {
89+
private CustomTransition customTransition;
90+
91+
CustomTransitionListenerAdapter(CustomTransition transition) {
92+
this.customTransition = transition;
93+
}
94+
95+
@Override
96+
public void onTransitionEnd(@NonNull Transition transition) {
97+
if (this.customTransition.resetOnTransitionEnd) {
98+
this.customTransition.immediateAnimatorSet.setDuration(0);
99+
this.customTransition.immediateAnimatorSet.setInterpolator(new ReverseInterpolator());
100+
this.customTransition.immediateAnimatorSet.start();
101+
this.customTransition.setResetOnTransitionEnd(false);
102+
}
103+
104+
this.customTransition.immediateAnimatorSet = null;
105+
this.customTransition = null;
106+
transition.removeListener(this);
107+
}
108+
}
109+
110+
private static class CustomAnimatorListener extends AnimatorListenerAdapter {
111+
112+
private final View mView;
113+
private boolean mLayerTypeChanged = false;
114+
115+
CustomAnimatorListener(View view) {
116+
mView = view;
117+
}
118+
119+
@Override
120+
public void onAnimationStart(Animator animation) {
121+
if (ViewCompat.hasOverlappingRendering(mView)
122+
&& mView.getLayerType() == View.LAYER_TYPE_NONE) {
123+
mLayerTypeChanged = true;
124+
mView.setLayerType(View.LAYER_TYPE_HARDWARE, null);
125+
}
126+
}
127+
128+
@Override
129+
public void onAnimationEnd(Animator animation) {
130+
if (mLayerTypeChanged) {
131+
mView.setLayerType(View.LAYER_TYPE_NONE, null);
132+
}
133+
}
134+
}
135+
}

tns-core-modules-widgets/android/widgets/src/main/java/org/nativescript/widgets/FragmentBase.java

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,6 @@
44
import androidx.fragment.app.Fragment;
55

66
public abstract class FragmentBase extends Fragment {
7-
8-
@Override
9-
public Animator onCreateAnimator(int transit, boolean enter, int nextAnim) {
10-
// [nested frames / fragments] apply dummy animator to the nested fragment with
11-
// the same duration as the exit animator of the removing parent fragment to work around
12-
// https://code.google.com/p/android/issues/detail?id=55228 (child fragments disappear
13-
// when parent fragment is removed as all children are first removed from parent)
14-
if (!enter) {
15-
Fragment removingParentFragment = this.getRemovingParentFragment();
16-
if (removingParentFragment != null) {
17-
Animator parentAnimator = removingParentFragment.onCreateAnimator(transit, enter, AnimatorHelper.exitFakeResourceId);
18-
if (parentAnimator != null) {
19-
long duration = AnimatorHelper.getTotalDuration(parentAnimator);
20-
return AnimatorHelper.createDummyAnimator(duration);
21-
}
22-
}
23-
}
24-
25-
return super.onCreateAnimator(transit, enter, nextAnim);
26-
}
27-
287
public Fragment getRemovingParentFragment() {
298
Fragment parentFragment = this.getParentFragment();
309
while (parentFragment != null && !parentFragment.isRemoving()) {
@@ -33,4 +12,4 @@ public Fragment getRemovingParentFragment() {
3312

3413
return parentFragment;
3514
}
36-
}
15+
}

tns-core-modules/ui/bottom-navigation/bottom-navigation.android.ts

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const ownerSymbol = Symbol("_owner");
3434
let TabFragment: any;
3535
let BottomNavigationBar: any;
3636
let AttachStateChangeListener: any;
37+
let appResources: android.content.res.Resources;
3738

3839
function makeFragmentName(viewId: number, id: number): string {
3940
return "android:bottomnavigation:" + viewId + ":" + id;
@@ -55,8 +56,9 @@ function initializeNativeClasses() {
5556
}
5657

5758
class TabFragmentImplementation extends org.nativescript.widgets.FragmentBase {
58-
private tab: BottomNavigation;
59+
private owner: BottomNavigation;
5960
private index: number;
61+
private backgroundBitmap: android.graphics.Bitmap = null;
6062

6163
constructor() {
6264
super();
@@ -77,18 +79,61 @@ function initializeNativeClasses() {
7779
public onCreate(savedInstanceState: android.os.Bundle): void {
7880
super.onCreate(savedInstanceState);
7981
const args = this.getArguments();
80-
this.tab = getTabById(args.getInt(TABID));
82+
this.owner = getTabById(args.getInt(TABID));
8183
this.index = args.getInt(INDEX);
82-
if (!this.tab) {
84+
if (!this.owner) {
8385
throw new Error(`Cannot find BottomNavigation`);
8486
}
8587
}
8688

8789
public onCreateView(inflater: android.view.LayoutInflater, container: android.view.ViewGroup, savedInstanceState: android.os.Bundle): android.view.View {
88-
const tabItem = this.tab.items[this.index];
90+
const tabItem = this.owner.items[this.index];
8991

9092
return tabItem.nativeViewProtected;
9193
}
94+
95+
public onDestroyView() {
96+
const hasRemovingParent = this.getRemovingParentFragment();
97+
98+
// Get view as bitmap and set it as background. This is workaround for the disapearing nested fragments.
99+
// TODO: Consider removing it when update to androidx.fragment:1.2.0
100+
if (hasRemovingParent && this.owner.selectedIndex === this.index) {
101+
const bitmapDrawable = new android.graphics.drawable.BitmapDrawable(appResources, this.backgroundBitmap);
102+
this.owner._originalBackground = this.owner.backgroundColor || new Color("White");
103+
this.owner.nativeViewProtected.setBackgroundDrawable(bitmapDrawable);
104+
this.backgroundBitmap = null;
105+
}
106+
107+
super.onDestroyView();
108+
}
109+
110+
public onPause(): void {
111+
const hasRemovingParent = this.getRemovingParentFragment();
112+
113+
// Get view as bitmap and set it as background. This is workaround for the disapearing nested fragments.
114+
// TODO: Consider removing it when update to androidx.fragment:1.2.0
115+
if (hasRemovingParent && this.owner.selectedIndex === this.index) {
116+
this.backgroundBitmap = this.loadBitmapFromView(this.owner.nativeViewProtected);
117+
}
118+
119+
super.onPause();
120+
}
121+
122+
private loadBitmapFromView(view: android.view.View): android.graphics.Bitmap {
123+
// Another way to get view bitmap. Test performance vs setDrawingCacheEnabled
124+
// const width = view.getWidth();
125+
// const height = view.getHeight();
126+
// const bitmap = android.graphics.Bitmap.createBitmap(width, height, android.graphics.Bitmap.Config.ARGB_8888);
127+
// const canvas = new android.graphics.Canvas(bitmap);
128+
// view.layout(0, 0, width, height);
129+
// view.draw(canvas);
130+
131+
view.setDrawingCacheEnabled(true);
132+
const bitmap = android.graphics.Bitmap.createBitmap(view.getDrawingCache());
133+
view.setDrawingCacheEnabled(false);
134+
135+
return bitmap;
136+
}
92137
}
93138

94139
class BottomNavigationBarImplementation extends org.nativescript.widgets.BottomNavigationBar {
@@ -168,6 +213,7 @@ function initializeNativeClasses() {
168213
TabFragment = TabFragmentImplementation;
169214
BottomNavigationBar = BottomNavigationBarImplementation;
170215
AttachStateChangeListener = new AttachListener();
216+
appResources = application.android.context.getResources();
171217
}
172218

173219
function setElevation(bottomNavigationBar: org.nativescript.widgets.BottomNavigationBar) {
@@ -196,6 +242,7 @@ export class BottomNavigation extends TabNavigationBase {
196242
private _currentFragment: androidx.fragment.app.Fragment;
197243
private _currentTransaction: androidx.fragment.app.FragmentTransaction;
198244
private _attachedToWindow = false;
245+
public _originalBackground: any;
199246

200247
constructor() {
201248
super();
@@ -320,6 +367,12 @@ export class BottomNavigation extends TabNavigationBase {
320367
public onLoaded(): void {
321368
super.onLoaded();
322369

370+
if (this._originalBackground) {
371+
this.backgroundColor = null;
372+
this.backgroundColor = this._originalBackground;
373+
this._originalBackground = null;
374+
}
375+
323376
if (this.tabStrip) {
324377
this.setTabStripItems(this.tabStrip.items);
325378
} else {
@@ -334,8 +387,14 @@ export class BottomNavigation extends TabNavigationBase {
334387

335388
_onAttachedToWindow(): void {
336389
super._onAttachedToWindow();
337-
338390
this._attachedToWindow = true;
391+
392+
// _onAttachedToWindow called from OS again after it was detach
393+
// TODO: Consider testing and removing it when update to androidx.fragment:1.2.0
394+
if (this._manager && this._manager.isDestroyed()) {
395+
return;
396+
}
397+
339398
this.changeTab(this.selectedIndex);
340399
}
341400

tns-core-modules/ui/core/view/view-common.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition {
8484
public static showingModallyEvent = "showingModally";
8585

8686
protected _closeModalCallback: Function;
87-
87+
public _manager: any;
8888
public _modalParent: ViewCommon;
8989
private _modalContext: any;
9090
private _modal: ViewCommon;

tns-core-modules/ui/core/view/view.android.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,12 +272,12 @@ export class View extends ViewCommon {
272272
public static androidBackPressedEvent = androidBackPressedEvent;
273273

274274
public _dialogFragment: androidx.fragment.app.DialogFragment;
275+
public _manager: androidx.fragment.app.FragmentManager;
275276
private _isClickable: boolean;
276277
private touchListenerIsSet: boolean;
277278
private touchListener: android.view.View.OnTouchListener;
278279
private layoutChangeListenerIsSet: boolean;
279280
private layoutChangeListener: android.view.View.OnLayoutChangeListener;
280-
private _manager: androidx.fragment.app.FragmentManager;
281281
private _rootManager: androidx.fragment.app.FragmentManager;
282282

283283
nativeViewProtected: android.view.View;

tns-core-modules/ui/core/view/view.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,11 @@ export abstract class View extends ViewBase {
643643
* @private
644644
*/
645645
_gestureObservers: any;
646+
/**
647+
* @private
648+
* androidx.fragment.app.FragmentManager
649+
*/
650+
_manager: any;
646651
/**
647652
* @private
648653
*/

tns-core-modules/ui/frame/fragment.android.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ class FragmentClass extends org.nativescript.widgets.FragmentBase {
2323
this._callbacks.onStop(this, super.onStop);
2424
}
2525

26+
public onPause(): void {
27+
this._callbacks.onPause(this, super.onStop);
28+
}
29+
2630
public onCreate(savedInstanceState: android.os.Bundle) {
2731
if (!this._callbacks) {
2832
setFragmentCallbacks(this);

0 commit comments

Comments
 (0)