From 8141737f748b25af4a87a2420a53f92345a1c946 Mon Sep 17 00:00:00 2001 From: Martin Yankov Date: Thu, 8 Mar 2018 14:39:59 +0200 Subject: [PATCH] fix-next(tabview): visually pre-load tab items on android (#5495) --- apps/app/ui-tests-app/app.ts | 1 + .../ui/tab-view/tab-view-navigation-tests.ts | 12 +- tests/app/ui/tab-view/tab-view-root-tests.ts | 280 ++++++++++++++---- tns-core-modules/ui/frame/frame.android.ts | 23 +- .../ui/tab-view/tab-view-common.ts | 19 +- .../ui/tab-view/tab-view.android.ts | 72 ++++- tns-core-modules/ui/tab-view/tab-view.ios.ts | 32 +- 7 files changed, 347 insertions(+), 92 deletions(-) diff --git a/apps/app/ui-tests-app/app.ts b/apps/app/ui-tests-app/app.ts index 535636a81..cdeed4d82 100644 --- a/apps/app/ui-tests-app/app.ts +++ b/apps/app/ui-tests-app/app.ts @@ -5,6 +5,7 @@ import * as trace from "tns-core-modules/trace"; trace.addCategories(trace.categories.NativeLifecycle); trace.addCategories(trace.categories.Navigation); trace.addCategories(trace.categories.Transition); + trace.enable(); var countResume = 0; diff --git a/tests/app/ui/tab-view/tab-view-navigation-tests.ts b/tests/app/ui/tab-view/tab-view-navigation-tests.ts index 805aa4108..11a627648 100644 --- a/tests/app/ui/tab-view/tab-view-navigation-tests.ts +++ b/tests/app/ui/tab-view/tab-view-navigation-tests.ts @@ -1,5 +1,6 @@ import * as TKUnit from "../../TKUnit"; import * as helper from "../helper"; +import { isIOS, isAndroid } from "tns-core-modules/platform"; import { Label } from "tns-core-modules/ui/label"; import { StackLayout } from "tns-core-modules/ui/layouts/stack-layout"; import * as frameModule from "tns-core-modules/ui/frame"; @@ -201,8 +202,15 @@ export function testLoadedAndUnloadedAreFired_WhenNavigatingAwayAndBack() { topFrame.currentPage.id = null; - TKUnit.arrayAssert(loadedEventsCount, [2, 0]); - TKUnit.arrayAssert(unloadedEventsCount, [1, 0]); + if (isIOS) { + TKUnit.arrayAssert(loadedEventsCount, [2, 0]); + TKUnit.arrayAssert(unloadedEventsCount, [1, 0]); + } + + if (isAndroid) { + TKUnit.arrayAssert(loadedEventsCount, [2, 2]); + TKUnit.arrayAssert(unloadedEventsCount, [1, 1]); + } } function _clickTheFirstButtonInTheListViewNatively(tabView: TabView) { diff --git a/tests/app/ui/tab-view/tab-view-root-tests.ts b/tests/app/ui/tab-view/tab-view-root-tests.ts index 501df9d93..399964256 100644 --- a/tests/app/ui/tab-view/tab-view-root-tests.ts +++ b/tests/app/ui/tab-view/tab-view-root-tests.ts @@ -1,81 +1,253 @@ import * as helper from "../helper"; import TKUnit = require("../../TKUnit"); +import { isIOS, isAndroid } from "tns-core-modules/platform"; import { _resetRootView } from "tns-core-modules/application/"; import { Frame, NavigationEntry } from "tns-core-modules/ui/frame"; import { Page } from "tns-core-modules/ui/page"; import { TabView, TabViewItem } from "tns-core-modules/ui/tab-view"; -export function test_whenChangingTabsWithFramesCorrectEventsAreRaised() { - const actualEventsRaised = []; - - function attachPageEventHandlers(page: Page) { - page.on(Page.loadedEvent, () => actualEventsRaised.push(`${page.id} loaded`)); - page.on(Page.unloadedEvent, () => actualEventsRaised.push(`${page.id} unloaded`)); - page.on(Page.navigatingToEvent, () => actualEventsRaised.push(`${page.id} navigatingTo`)); - page.on(Page.navigatingFromEvent, () => actualEventsRaised.push(`${page.id} navigatingFrom`)); - page.on(Page.navigatedToEvent, () => actualEventsRaised.push(`${page.id} navigatedTo`)); - page.on(Page.navigatedFromEvent, () => actualEventsRaised.push(`${page.id} navigatedFrom`)); +function waitUntilNavigatedTo(pages: Page[], action: Function) { + let completed = 0; + function navigatedTo(args) { + args.object.page.off("navigatedTo", navigatedTo); + completed++; } - function attachFrameEventHandlers(frame: Frame) { - frame.on(Frame.loadedEvent, () => actualEventsRaised.push(`${frame.id} loaded`)); - frame.on(Frame.unloadedEvent, () => actualEventsRaised.push(`${frame.id} unloaded`)); + pages.forEach(page => page.on("navigatedTo", navigatedTo)); + action(); + TKUnit.waitUntilReady(() => completed === pages.length, 100); +} + +function createPage(i: number) { + const page = new Page(); + page.id = `Tab${i} Frame${i} Page${i}`; + + return page; +} + +function createFrame(i: number, page: Page) { + const frame = new Frame(); + frame.navigate(() => page); + frame.id = `Tab${i} Frame${i}`; + + return frame; +} + +function createTabItem(i: number, frame: Frame) { + const tabEntry = new TabViewItem(); + tabEntry.title = "Tab " + i; + tabEntry.view = frame; + tabEntry["index"] = i; + + return tabEntry; +} + +function createTabItemsWithFrames(count: number) { + const items = []; + + for (var i = 0; i < count; i++) { + const page = createPage(i); + const frame = createFrame(i, page); + const tabItem = createTabItem(i, frame); + + items.push({ page, frame, tabItem }); } - const page1 = new Page(); - page1.id = "Tab1 Frame1 Page1"; - attachPageEventHandlers(page1); + return items; +} - const frame1 = new Frame(); - frame1.navigate(() => page1); - frame1.id = "Tab1 Frame1"; - attachFrameEventHandlers(frame1); +export function test_ios_and_android_offset_zero_should_raise_same_events() { + let actualEventsRaised = []; - const page2 = new Page(); - page2.id = "Tab2 Frame2 Page2"; - attachPageEventHandlers(page2); + function resetActualEventsRaised() { + for (var i = 0; i < actualEventsRaised.length; i++) { + actualEventsRaised[i] = []; + } + } - const frame2 = new Frame(); - frame2.navigate(() => page2); - frame2.id = "Tab2 Frame2"; - attachFrameEventHandlers(frame2); + function attachEventHandlers(i: number, item) { + actualEventsRaised.push([]); + + const page = item.page; + page.on(Page.loadedEvent, () => actualEventsRaised[i].push(`${page.id} loaded`)); + page.on(Page.unloadedEvent, () => actualEventsRaised[i].push(`${page.id} unloaded`)); + page.on(Page.navigatingToEvent, () => actualEventsRaised[i].push(`${page.id} navigatingTo`)); + page.on(Page.navigatingFromEvent, () => actualEventsRaised[i].push(`${page.id} navigatingFrom`)); + page.on(Page.navigatedToEvent, () => actualEventsRaised[i].push(`${page.id} navigatedTo`)); + page.on(Page.navigatedFromEvent, () => actualEventsRaised[i].push(`${page.id} navigatedFrom`)); + + const frame = item.frame; + frame.on(Frame.loadedEvent, () => actualEventsRaised[i].push(`${frame.id} loaded`)); + frame.on(Frame.unloadedEvent, () => actualEventsRaised[i].push(`${frame.id} unloaded`)); + } + + const items = createTabItemsWithFrames(3); + + items.forEach((item, i) => { + attachEventHandlers(i, item); + }); const tabView = new TabView(); - const tabEntry1 = new TabViewItem(); - tabEntry1.title = "frame1"; - tabEntry1.view = frame1; - const tabEntry2 = new TabViewItem(); - tabEntry2.title = "frame2"; - tabEntry2.view = frame2; - tabView.items = [tabEntry1, tabEntry2]; + tabView.items = items.map(item => item.tabItem); + + // iOS cannot preload tab items + // Android preloads 1 tab item to the sides by default + // set this to 0, so that both platforms behave the same. + tabView.androidOffscreenTabLimit = 0; const entry: NavigationEntry = { create: () => tabView }; - helper.waitUntilNavigatedTo(page1, () => _resetRootView(entry)); - helper.waitUntilNavigatedTo(page2, () => tabView.selectedIndex = 1); - tabView.selectedIndex = 0; - TKUnit.waitUntilReady(() => page1.isLoaded); + helper.waitUntilNavigatedTo(items[0].page, () => _resetRootView(entry)); - const expectedEventsRaised = [ - "Tab1 Frame1 loaded", - "Tab1 Frame1 Page1 navigatingTo", - "Tab1 Frame1 Page1 loaded", - "Tab1 Frame1 Page1 navigatedTo", - "Tab1 Frame1 Page1 unloaded", - "Tab1 Frame1 unloaded", - "Tab2 Frame2 loaded", - "Tab2 Frame2 Page2 navigatingTo", - "Tab2 Frame2 Page2 loaded", - "Tab2 Frame2 Page2 navigatedTo", - "Tab2 Frame2 Page2 unloaded", - "Tab2 Frame2 unloaded", - "Tab1 Frame1 Page1 loaded", - "Tab1 Frame1 loaded" + const expectedEventsRaisedAfterTabCreated = [ + [ + "Tab0 Frame0 loaded", + "Tab0 Frame0 Page0 navigatingTo", + "Tab0 Frame0 Page0 loaded", + "Tab0 Frame0 Page0 navigatedTo", + ], + [], + [] ]; - TKUnit.arrayAssert(actualEventsRaised, expectedEventsRaised); + TKUnit.assertDeepEqual(actualEventsRaised, expectedEventsRaisedAfterTabCreated); + + resetActualEventsRaised(); + helper.waitUntilNavigatedTo(items[2].page, () => tabView.selectedIndex = 2); + + const expectedEventsRaisedAfterSelectThirdTab = [ + [ + "Tab0 Frame0 Page0 unloaded", + "Tab0 Frame0 unloaded", + ], + [], + [ + "Tab2 Frame2 loaded", + "Tab2 Frame2 Page2 navigatingTo", + "Tab2 Frame2 Page2 loaded", + "Tab2 Frame2 Page2 navigatedTo" + ] + ]; + + TKUnit.assertDeepEqual(actualEventsRaised, expectedEventsRaisedAfterSelectThirdTab); + + resetActualEventsRaised(); + tabView.selectedIndex = 0; + TKUnit.waitUntilReady(() => items[0].page.isLoaded); + + const expectedEventsRaisedAfterReturnToFirstTab = [ + [ + "Tab0 Frame0 Page0 loaded", + "Tab0 Frame0 loaded", + ], + [], + [ + "Tab2 Frame2 Page2 unloaded", + "Tab2 Frame2 unloaded", + ] + ]; + + TKUnit.assertDeepEqual(actualEventsRaised, expectedEventsRaisedAfterReturnToFirstTab); +} + +export function test_android_default_offset_should_preload_1_tab_on_each_side() { + let actualEventsRaised = []; + + function resetActualEventsRaised() { + for (var i = 0; i < actualEventsRaised.length; i++) { + actualEventsRaised[i] = []; + } + } + + function attachEventHandlers(i: number, item) { + actualEventsRaised.push([]); + + const page = item.page; + page.on(Page.loadedEvent, () => actualEventsRaised[i].push(`${page.id} loaded`)); + page.on(Page.unloadedEvent, () => actualEventsRaised[i].push(`${page.id} unloaded`)); + page.on(Page.navigatingToEvent, () => actualEventsRaised[i].push(`${page.id} navigatingTo`)); + page.on(Page.navigatingFromEvent, () => actualEventsRaised[i].push(`${page.id} navigatingFrom`)); + page.on(Page.navigatedToEvent, () => actualEventsRaised[i].push(`${page.id} navigatedTo`)); + page.on(Page.navigatedFromEvent, () => actualEventsRaised[i].push(`${page.id} navigatedFrom`)); + + const frame = item.frame; + frame.on(Frame.loadedEvent, () => actualEventsRaised[i].push(`${frame.id} loaded`)); + frame.on(Frame.unloadedEvent, () => actualEventsRaised[i].push(`${frame.id} unloaded`)); + } + + if (isAndroid) { + const items = createTabItemsWithFrames(3); + + items.forEach((item, i) => { + attachEventHandlers(i, item); + }); + + const tabView = new TabView(); + tabView.items = items.map(item => item.tabItem); + + const entry: NavigationEntry = { + create: () => tabView + }; + + waitUntilNavigatedTo([items[0].page, items[1].page], () => _resetRootView(entry)); + + const expectedEventsRaisedAfterTabCreated = [ + [ + "Tab0 Frame0 loaded", + "Tab0 Frame0 Page0 navigatingTo", + "Tab0 Frame0 Page0 loaded", + "Tab0 Frame0 Page0 navigatedTo", + ], + [ + "Tab1 Frame1 loaded", + "Tab1 Frame1 Page1 navigatingTo", + "Tab1 Frame1 Page1 loaded", + "Tab1 Frame1 Page1 navigatedTo", + ], + [] + ]; + + TKUnit.assertDeepEqual(actualEventsRaised, expectedEventsRaisedAfterTabCreated); + + resetActualEventsRaised(); + helper.waitUntilNavigatedTo(items[2].page, () => tabView.selectedIndex = 2); + + const expectedEventsRaisedAfterSelectThirdTab = [ + [ + "Tab0 Frame0 Page0 unloaded", + "Tab0 Frame0 unloaded", + ], + [], + [ + "Tab2 Frame2 loaded", + "Tab2 Frame2 Page2 navigatingTo", + "Tab2 Frame2 Page2 loaded", + "Tab2 Frame2 Page2 navigatedTo" + ] + ]; + + TKUnit.assertDeepEqual(actualEventsRaised, expectedEventsRaisedAfterSelectThirdTab); + + resetActualEventsRaised(); + tabView.selectedIndex = 0; + TKUnit.waitUntilReady(() => items[0].page.isLoaded); + + const expectedEventsRaisedAfterReturnToFirstTab = [ + [ + "Tab0 Frame0 Page0 loaded", + "Tab0 Frame0 loaded", + ], + [], + [ + "Tab2 Frame2 Page2 unloaded", + "Tab2 Frame2 unloaded", + ] + ]; + + TKUnit.assertDeepEqual(actualEventsRaised, expectedEventsRaisedAfterReturnToFirstTab); + } } export function tearDownModule() { diff --git a/tns-core-modules/ui/frame/frame.android.ts b/tns-core-modules/ui/frame/frame.android.ts index 13315258c..ddfae7881 100644 --- a/tns-core-modules/ui/frame/frame.android.ts +++ b/tns-core-modules/ui/frame/frame.android.ts @@ -176,6 +176,18 @@ export class Frame extends FrameBase { } } + onUnloaded() { + if (this._currentEntry && this._currentEntry.fragment) { + const manager: android.app.FragmentManager = this._getFragmentManager(); + + const transaction = manager.beginTransaction(); + transaction.remove(this._currentEntry.fragment); + transaction.commitAllowingStateLoss(); + } + + super.onUnloaded(); + } + private createFragment(backstackEntry: BackstackEntry, fragmentTag: string): android.app.Fragment { ensureFragmentClass(); const newFragment = new fragmentClass(); @@ -569,7 +581,8 @@ function findPageForFragment(fragment: android.app.Fragment, frame: Frame) { callbacks.entry = entry; entry.fragment = fragment; _updateTransitions(entry); - } else { + } + else { throw new Error(`Could not find a page for ${fragmentTag}.`); } } @@ -697,13 +710,7 @@ class FragmentCallbacksImplementation implements AndroidFragmentCallbacks { this.frame._addView(page); } - // Load page here even if root view is not loaded yet. - // Otherwise it will show as blank, - // The case is Tab->Frame->Page activity recreated, fragments are - // created before Tab loads its items. - // TODO: addCheck if the fragment is visible so we don't load pages - // that are not in the selectedIndex of the Tab!!!!!! - if (!page.isLoaded) { + if (frame.isLoaded && !page.isLoaded) { page.callLoaded(); } diff --git a/tns-core-modules/ui/tab-view/tab-view-common.ts b/tns-core-modules/ui/tab-view/tab-view-common.ts index 5a770382a..3f3c039e2 100644 --- a/tns-core-modules/ui/tab-view/tab-view-common.ts +++ b/tns-core-modules/ui/tab-view/tab-view-common.ts @@ -71,9 +71,8 @@ export abstract class TabViewItemBase extends ViewBase implements TabViewItemDef public loadView(view: ViewBase): void { const tabView = this.parent as TabViewBase; if (tabView && tabView.items) { - const index = tabView.items.indexOf(this); // Don't load items until their fragments are instantiated. - if (index === tabView.selectedIndex && (this).canBeLoaded) { + if ((this).canBeLoaded) { super.loadView(view); } } @@ -185,21 +184,7 @@ export class TabViewBase extends View implements TabViewDefinition, AddChildFrom } public onSelectedIndexChanged(oldIndex: number, newIndex: number): void { - const items = this.items; - if (!items) { - return; - } - - const oldItem = items[oldIndex]; - if (oldItem) { - oldItem.unloadView(oldItem.view); - } - - const newItem = items[newIndex]; - if (newItem && this.isLoaded) { - newItem.loadView(newItem.view); - } - + // to be overridden in platform specific files this.notify({ eventName: TabViewBase.selectedIndexChangedEvent, object: this, oldIndex, newIndex }); } } diff --git a/tns-core-modules/ui/tab-view/tab-view.android.ts b/tns-core-modules/ui/tab-view/tab-view.android.ts index 1c6ade69f..b32654a3e 100644 --- a/tns-core-modules/ui/tab-view/tab-view.android.ts +++ b/tns-core-modules/ui/tab-view/tab-view.android.ts @@ -5,7 +5,7 @@ import { TabViewBase, TabViewItemBase, itemsProperty, selectedIndexProperty, tabTextColorProperty, tabBackgroundColorProperty, selectedTabTextColorProperty, androidSelectedTabHighlightColorProperty, androidOffscreenTabLimitProperty, - fontSizeProperty, fontInternalProperty, View, layout, traceCategory, traceEnabled, + fontSizeProperty, fontInternalProperty, View, layout, traceCategory, traceEnabled, traceWrite, Color } from "./tab-view-common" import { textTransformProperty, TextTransform, getTransformedText } from "../text-base"; @@ -55,6 +55,7 @@ function initializeNativeClasses() { } static newInstance(tabId: number, index: number): TabFragmentImplementation { + const args = new android.os.Bundle(); args.putInt(TABID, tabId); args.putInt(INDEX, index); @@ -74,7 +75,13 @@ function initializeNativeClasses() { } public onCreateView(inflater: android.view.LayoutInflater, container: android.view.ViewGroup, savedInstanceState: android.os.Bundle): android.view.View { - return this.tab.items[this.index].view.nativeViewProtected; + const tabItem = this.tab.items[this.index]; + + return tabItem.view.nativeViewProtected; + } + + public onDestroyView() { + super.onDestroyView(); } } @@ -155,6 +162,10 @@ function initializeNativeClasses() { const fragment: android.app.Fragment = object; this.mCurTransaction.detach(fragment); + if (this.mCurrentPrimaryItem === fragment) { + this.mCurrentPrimaryItem = null; + } + const tabItems = this.owner.items; const tabItem = tabItems ? tabItems[position] : null; if (tabItem) { @@ -177,13 +188,14 @@ function initializeNativeClasses() { this.mCurrentPrimaryItem = fragment; this.owner.selectedIndex = position; - } - const tab = this.owner; - const tabItems = tab.items; - const newTabItem = tabItems ? tabItems[position] : null; - if (newTabItem && tab.isLoaded) { - newTabItem.loadView(newTabItem.view); + const tab = this.owner; + const tabItems = tab.items; + const newTabItem = tabItems ? tabItems[position] : null; + + if (newTabItem) { + tab._loadUnloadTabItems(tab.selectedIndex); + } } } @@ -336,6 +348,14 @@ function setElevation(grid: org.nativescript.widgets.GridLayout, tabLayout: org. export const tabs = new Array>(); +function iterateIndexRange(index: number, eps: number, lastIndex: number, callback: (i) => void) { + const rangeStart = Math.max(0, index - eps); + const rangeEnd = Math.min(index + eps, lastIndex); + for (let i = rangeStart; i <= rangeEnd; i++) { + callback(i); + } +} + export class TabView extends TabViewBase { private _tabLayout: org.nativescript.widgets.TabLayout; private _viewPager: android.support.v4.view.ViewPager; @@ -429,6 +449,38 @@ export class TabView extends TabViewBase { (this._pagerAdapter).owner = this; } + public _loadUnloadTabItems(newIndex: number) { + const items = this.items; + const lastIndex = this.items.length - 1; + const offsideItems = this.androidTabsPosition === "top" ? this.androidOffscreenTabLimit : 0; + + let toUnload = []; + let toLoad = []; + + iterateIndexRange(newIndex, offsideItems, lastIndex, (i) => toLoad.push(i)); + + items.forEach((item, i) => { + const indexOfI = toLoad.indexOf(i); + if (indexOfI < 0) { + toUnload.push(i); + } + }); + + toUnload.forEach(index => { + const item = items[index]; + if (items[index]) { + item.unloadView(item.view); + } + }); + + toLoad.forEach(index => { + const item = items[index]; + if (this.isLoaded && items[index]) { + item.loadView(item.view); + } + }); + } + public onLoaded(): void { super.onLoaded(); @@ -464,7 +516,7 @@ export class TabView extends TabViewBase { if (!this._pagerAdapter) { return false; } - + const currentPagerAdapterItems = (this._pagerAdapter).items; // if both values are null, should not update @@ -483,7 +535,7 @@ export class TabView extends TabViewBase { } const matchingItems = currentPagerAdapterItems.filter((currentItem) => { - return !!items.filter((item) => { + return !!items.filter((item) => { return item._domId === currentItem._domId })[0]; }); diff --git a/tns-core-modules/ui/tab-view/tab-view.ios.ts b/tns-core-modules/ui/tab-view/tab-view.ios.ts index 981f60431..780995593 100644 --- a/tns-core-modules/ui/tab-view/tab-view.ios.ts +++ b/tns-core-modules/ui/tab-view/tab-view.ios.ts @@ -1,7 +1,7 @@ import { TabViewItem as TabViewItemDefinition } from "."; import { Font } from "../styling/font"; -import { ios as iosView } from "../core/view"; +import { ios as iosView, ViewBase } from "../core/view"; import { TabViewBase, TabViewItemBase, itemsProperty, selectedIndexProperty, tabTextColorProperty, tabBackgroundColorProperty, selectedTabTextColorProperty, iosIconRenderingModeProperty, @@ -158,6 +158,17 @@ export class TabViewItem extends TabViewItemBase { this.setNativeView(undefined); } + public loadView(view: ViewBase): void { + const tabView = this.parent as TabViewBase; + if (tabView && tabView.items) { + const index = tabView.items.indexOf(this); + + if (index === tabView.selectedIndex) { + super.loadView(view); + } + } + } + public _update() { const parent = this.parent; const controller = this.__controller; @@ -229,6 +240,25 @@ export class TabView extends TabViewBase { // } + public onSelectedIndexChanged(oldIndex: number, newIndex: number): void { + const items = this.items; + if (!items) { + return; + } + + const oldItem = items[oldIndex]; + if (oldItem) { + oldItem.unloadView(oldItem.view); + } + + const newItem = items[newIndex]; + if (newItem && this.isLoaded) { + newItem.loadView(newItem.view); + } + + super.onSelectedIndexChanged(oldIndex, newIndex); + } + public onMeasure(widthMeasureSpec: number, heightMeasureSpec: number): void { const width = layout.getMeasureSpecSize(widthMeasureSpec); const widthMode = layout.getMeasureSpecMode(widthMeasureSpec);