diff --git a/e2e/AndroidUtils.js b/e2e/AndroidUtils.js index 6421abf416e..e594e7bd55d 100644 --- a/e2e/AndroidUtils.js +++ b/e2e/AndroidUtils.js @@ -15,6 +15,28 @@ const utils = { executeShellCommand: (command) => { exec.execSync(`adb -s ${device.id} shell ${command}`); }, + setDemoMode: () => { + // enter demo mode + utils.executeShellCommand('settings put global sysui_demo_allowed 1'); + // display time 12:00 + utils.executeShellCommand( + 'am broadcast -a com.android.systemui.demo -e command clock -e hhmm 1200' + ); + // Display full mobile data with 4g type and no wifi + utils.executeShellCommand( + 'am broadcast -a com.android.systemui.demo -e command network -e mobile show -e level 4 -e datatype 4g -e wifi false' + ); + // Hide notifications + utils.executeShellCommand( + 'am broadcast -a com.android.systemui.demo -e command notifications -e visible false' + ); + // Disable pointer location + utils.executeShellCommand('settings put system pointer_location 0'); + // Show full battery but not in charging state + utils.executeShellCommand( + 'am broadcast -a com.android.systemui.demo -e command battery -e plugged false -e level 100' + ); + }, }; export default utils; diff --git a/e2e/Overlay.test.js b/e2e/Overlay.test.js index e8280d7f416..029af0bc777 100644 --- a/e2e/Overlay.test.js +++ b/e2e/Overlay.test.js @@ -1,7 +1,8 @@ import Utils from './Utils'; import TestIDs from '../playground/src/testIDs'; +import Android from './AndroidUtils'; -const { elementByLabel, elementById } = Utils; +const { elementByLabel, elementById, expectImagesToBeEqual,expectImagesToBeNotEqual } = Utils; describe('Overlay', () => { beforeEach(async () => { @@ -54,6 +55,27 @@ describe('Overlay', () => { await elementById(TestIDs.HIDE_TOP_BAR_BUTTON).tap(); await expect(elementById(TestIDs.TOP_BAR_ELEMENT)).toBeVisible(); }); + + it.e2e(':android: should show banner overlay and not block the screen', async () => { + const snapshottedImagePath = './e2e/assets/overlay_banner_padding.png'; + Android.setDemoMode(); + let expected = await device.takeScreenshot('without_banner'); + await elementById(TestIDs.SHOW_BANNER_OVERLAY).tap(); + await expect(elementById(TestIDs.BANNER_OVERLAY)).toBeVisible(); + const actual = await device.takeScreenshot('with_banner'); + expectImagesToBeNotEqual(expected, actual) + await elementById(TestIDs.SET_LAYOUT_BOTTOM_INSETS).tap(); + expected = await device.takeScreenshot('with_banner'); + expectImagesToBeEqual(expected, snapshottedImagePath) + }); + + it.e2e(':ios: should show banner overlay and not block the screen', async () => { + await elementById(TestIDs.SHOW_BANNER_OVERLAY).tap(); + await expect(elementById(TestIDs.BANNER_OVERLAY)).toBeVisible(); + await expect(elementById(TestIDs.FOOTER_TEXT)).toBeNotVisible(); + await elementById(TestIDs.SET_LAYOUT_BOTTOM_INSETS).tap(); + await expect(elementById(TestIDs.FOOTER_TEXT)).toBeVisible(); + }); }); describe('Overlay Dismiss all', () => { diff --git a/e2e/Utils.js b/e2e/Utils.js index 6d109c8056f..1c96482aaac 100644 --- a/e2e/Utils.js +++ b/e2e/Utils.js @@ -1,3 +1,14 @@ +import { readFileSync } from 'fs'; +function bitmapDiff(imagePath, expectedImagePath) { + const PNG = require('pngjs').PNG; + const pixelmatch = require('pixelmatch'); + const img1 = PNG.sync.read(readFileSync(imagePath)); + const img2 = PNG.sync.read(readFileSync(expectedImagePath)); + const {width, height} = img1; + const diff = new PNG({width, height}); + + return pixelmatch(img1.data, img2.data, diff.data, width, height, {threshold: 0.0}) +} const utils = { elementByLabel: (label) => { return element(by.text(label)); @@ -18,6 +29,18 @@ const utils = { } }, sleep: (ms) => new Promise((res) => setTimeout(res, ms)), + expectImagesToBeEqual:(imagePath, expectedImagePath)=>{ + let diff = bitmapDiff(imagePath,expectedImagePath); + if(diff!==0){ + throw Error(`${imagePath} should be the same as ${expectedImagePath}, with diff: ${diff}`) + } + } , + expectImagesToBeNotEqual:(imagePath, expectedImagePath)=>{ + let diff = bitmapDiff(imagePath,expectedImagePath); + if(diff===0){ + throw Error(`${imagePath} should be the same as ${expectedImagePath}, with diff: ${diff}`) + } + } }; export default utils; diff --git a/e2e/assets/overlay_banner_padding.png b/e2e/assets/overlay_banner_padding.png new file mode 100644 index 00000000000..cc73e02e2f8 Binary files /dev/null and b/e2e/assets/overlay_banner_padding.png differ diff --git a/e2e/iOSUtils.js b/e2e/iOSUtils.js new file mode 100644 index 00000000000..df457aa934e --- /dev/null +++ b/e2e/iOSUtils.js @@ -0,0 +1,11 @@ +import { execSync } from 'shell-utils/src/exec'; + +const utils = { + setDemoMode: () => { + execSync( + 'xcrun simctl status_bar "iPhone 11" override --time "12:00" --batteryState charged --batteryLevel 100 --wifiBars 3 --cellularMode active --cellularBars 4' + ); + }, +}; + +export default utils; diff --git a/lib/android/app/src/main/java/com/reactnativenavigation/options/LayoutOptions.java b/lib/android/app/src/main/java/com/reactnativenavigation/options/LayoutOptions.java deleted file mode 100644 index 7eebc6d81de..00000000000 --- a/lib/android/app/src/main/java/com/reactnativenavigation/options/LayoutOptions.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.reactnativenavigation.options; - -import android.content.Context; - -import com.reactnativenavigation.options.params.NullNumber; -import com.reactnativenavigation.options.params.Number; -import com.reactnativenavigation.options.params.ThemeColour; -import com.reactnativenavigation.options.params.NullThemeColour; -import com.reactnativenavigation.options.parsers.NumberParser; - -import org.json.JSONObject; - -public class LayoutOptions { - public static LayoutOptions parse(Context context, JSONObject json) { - LayoutOptions result = new LayoutOptions(); - if (json == null) return result; - - result.backgroundColor = ThemeColour.parse(context, json.optJSONObject("backgroundColor")); - result.componentBackgroundColor = ThemeColour.parse(context, json.optJSONObject("componentBackgroundColor")); - result.topMargin = NumberParser.parse(json, "topMargin"); - result.orientation = OrientationOptions.parse(json); - result.direction = LayoutDirection.fromString(json.optString("direction", "")); - - return result; - } - - public ThemeColour backgroundColor = new NullThemeColour(); - public ThemeColour componentBackgroundColor = new NullThemeColour(); - public Number topMargin = new NullNumber(); - public OrientationOptions orientation = new OrientationOptions(); - public LayoutDirection direction = LayoutDirection.DEFAULT; - - public void mergeWith(LayoutOptions other) { - if (other.backgroundColor.hasValue()) backgroundColor = other.backgroundColor; - if (other.componentBackgroundColor.hasValue()) componentBackgroundColor = other.componentBackgroundColor; - if (other.topMargin.hasValue()) topMargin = other.topMargin; - if (other.orientation.hasValue()) orientation = other.orientation; - if (other.direction.hasValue()) direction = other.direction; - } - - public void mergeWithDefault(LayoutOptions defaultOptions) { - if (!backgroundColor.hasValue()) backgroundColor = defaultOptions.backgroundColor; - if (!componentBackgroundColor.hasValue()) componentBackgroundColor = defaultOptions.componentBackgroundColor; - if (!topMargin.hasValue()) topMargin = defaultOptions.topMargin; - if (!orientation.hasValue()) orientation = defaultOptions.orientation; - if (!direction.hasValue()) direction = defaultOptions.direction; - } -} diff --git a/lib/android/app/src/main/java/com/reactnativenavigation/options/Options.java b/lib/android/app/src/main/java/com/reactnativenavigation/options/Options.java index a169c67c0e8..076b0e4194d 100644 --- a/lib/android/app/src/main/java/com/reactnativenavigation/options/Options.java +++ b/lib/android/app/src/main/java/com/reactnativenavigation/options/Options.java @@ -2,6 +2,8 @@ import android.content.Context; + +import com.reactnativenavigation.options.layout.LayoutOptions; import com.reactnativenavigation.options.params.NullNumber; import com.reactnativenavigation.options.params.NullText; import com.reactnativenavigation.options.parsers.TypefaceLoader; diff --git a/lib/android/app/src/main/java/com/reactnativenavigation/options/layout/LayoutInsets.kt b/lib/android/app/src/main/java/com/reactnativenavigation/options/layout/LayoutInsets.kt new file mode 100644 index 00000000000..8b231753d6f --- /dev/null +++ b/lib/android/app/src/main/java/com/reactnativenavigation/options/layout/LayoutInsets.kt @@ -0,0 +1,44 @@ +package com.reactnativenavigation.options.layout + +import com.reactnativenavigation.utils.dp +import org.json.JSONObject + +class LayoutInsets( + var top: Int?=null, + var left: Int?=null, + var bottom: Int?=null, + var right: Int?=null +) { + fun merge(toMerge: LayoutInsets?, defaults: LayoutInsets?) { + toMerge?.let { options-> + options.top?.let { this.top = it } + options.bottom?.let { this.bottom = it } + options.left?.let { this.left = it } + options.right?.let { this.right = it } + } + + defaults?.let { + options-> + top = top?:options.top + left = left?:options.left + right = right?:options.right + bottom = bottom?:options.bottom + } + } + + companion object{ + fun parse(jsonObject: JSONObject?): LayoutInsets { + return LayoutInsets( + jsonObject?.optInt("top")?.dp, + jsonObject?.optInt("left")?.dp, + jsonObject?.optInt("bottom")?.dp, + jsonObject?.optInt("right")?.dp + ) + } + } + + fun hasValue(): Boolean { + return top!=null || bottom!=null || left!=null || right!=null + } + +} diff --git a/lib/android/app/src/main/java/com/reactnativenavigation/options/layout/LayoutOptions.kt b/lib/android/app/src/main/java/com/reactnativenavigation/options/layout/LayoutOptions.kt new file mode 100644 index 00000000000..92e21150406 --- /dev/null +++ b/lib/android/app/src/main/java/com/reactnativenavigation/options/layout/LayoutOptions.kt @@ -0,0 +1,71 @@ +package com.reactnativenavigation.options.layout + +import android.content.Context +import com.reactnativenavigation.options.LayoutDirection +import com.reactnativenavigation.options.OrientationOptions +import com.reactnativenavigation.options.params.* +import com.reactnativenavigation.options.params.Number +import com.reactnativenavigation.options.parsers.BoolParser +import com.reactnativenavigation.options.parsers.NumberParser +import org.json.JSONObject + +class LayoutOptions { + @JvmField + var backgroundColor: ThemeColour = NullThemeColour() + + @JvmField + var componentBackgroundColor: ThemeColour = NullThemeColour() + + @JvmField + var topMargin: Number = NullNumber() + + @JvmField + var adjustResize: Bool = NullBool() + + @JvmField + var orientation = OrientationOptions() + + @JvmField + var direction = LayoutDirection.DEFAULT + + var insets: LayoutInsets = LayoutInsets() + + + fun mergeWith(other: LayoutOptions) { + if (other.backgroundColor.hasValue()) backgroundColor = other.backgroundColor + if (other.componentBackgroundColor.hasValue()) componentBackgroundColor = other.componentBackgroundColor + if (other.topMargin.hasValue()) topMargin = other.topMargin + if (other.orientation.hasValue()) orientation = other.orientation + if (other.direction.hasValue()) direction = other.direction + if (other.adjustResize.hasValue()) adjustResize = other.adjustResize + insets.merge(other.insets, null) + } + + fun mergeWithDefault(defaultOptions: LayoutOptions) { + if (!backgroundColor.hasValue()) backgroundColor = defaultOptions.backgroundColor + if (!componentBackgroundColor.hasValue()) componentBackgroundColor = defaultOptions.componentBackgroundColor + if (!topMargin.hasValue()) topMargin = defaultOptions.topMargin + if (!orientation.hasValue()) orientation = defaultOptions.orientation + if (!direction.hasValue()) direction = defaultOptions.direction + if (!adjustResize.hasValue()) adjustResize = defaultOptions.adjustResize + insets.merge(null, defaultOptions.insets) + + } + + companion object { + @JvmStatic + fun parse(context: Context?, json: JSONObject?): LayoutOptions { + val result = LayoutOptions() + if (json == null) return result + result.backgroundColor = ThemeColour.parse(context!!, json.optJSONObject("backgroundColor")) + result.componentBackgroundColor = ThemeColour.parse(context, json.optJSONObject("componentBackgroundColor")) + result.topMargin = NumberParser.parse(json, "topMargin") + result.insets = LayoutInsets.parse(json.optJSONObject("insets")) + result.orientation = OrientationOptions.parse(json) + result.direction = LayoutDirection.fromString(json.optString("direction", "")) + result.adjustResize = BoolParser.parse(json, "adjustResize") + return result + } + } + +} \ No newline at end of file diff --git a/lib/android/app/src/main/java/com/reactnativenavigation/utils/PrimitiveExt.kt b/lib/android/app/src/main/java/com/reactnativenavigation/utils/PrimitiveExt.kt new file mode 100644 index 00000000000..5b4e5fa05e3 --- /dev/null +++ b/lib/android/app/src/main/java/com/reactnativenavigation/utils/PrimitiveExt.kt @@ -0,0 +1,9 @@ +package com.reactnativenavigation.utils + +import android.content.res.Resources + +val Int.dp: Int + get() = (this * Resources.getSystem().displayMetrics.density).toInt() + +val Float.dp: Int + get() = (this * Resources.getSystem().displayMetrics.density).toInt() \ No newline at end of file diff --git a/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/component/ComponentViewController.java b/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/component/ComponentViewController.java index 2a5d3523a5c..714bb20e853 100644 --- a/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/component/ComponentViewController.java +++ b/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/component/ComponentViewController.java @@ -144,16 +144,17 @@ public void applyBottomInset() { protected WindowInsetsCompat onApplyWindowInsets(View view, WindowInsetsCompat insets) { ViewController viewController = findController(view); if (viewController == null || viewController.getView() == null) return insets; - final Insets keyboardInsets = insets.getInsets( WindowInsetsCompat.Type.ime()); + final int keyboardBottomInset = options.layout.adjustResize.get(true) ? insets.getInsets( WindowInsetsCompat.Type.ime()).bottom : 0; final Insets systemBarsInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars() ); final int visibleNavBar = resolveCurrentOptions(presenter.defaultOptions).navigationBar.isVisible.isTrueOrUndefined()?1:0; final WindowInsetsCompat finalInsets = new WindowInsetsCompat.Builder().setInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.ime(), Insets.of(systemBarsInsets.left, 0, systemBarsInsets.right, - Math.max(visibleNavBar*systemBarsInsets.bottom,keyboardInsets.bottom)) + Math.max(visibleNavBar*systemBarsInsets.bottom,keyboardBottomInset)) ).build(); - return ViewCompat.onApplyWindowInsets(viewController.getView(), finalInsets); + ViewCompat.onApplyWindowInsets(viewController.getView(), finalInsets); + return insets; } @Override diff --git a/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/navigator/Navigator.java b/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/navigator/Navigator.java index 38d663b8455..80e6acfd624 100644 --- a/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/navigator/Navigator.java +++ b/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/navigator/Navigator.java @@ -4,11 +4,13 @@ import android.content.res.Configuration; import android.view.View; import android.view.ViewGroup; +import android.view.WindowInsets; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RestrictTo; import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.core.view.WindowInsetsCompat; import com.facebook.react.ReactInstanceManager; import com.reactnativenavigation.options.Options; @@ -82,6 +84,8 @@ public Navigator(final Activity activity, ChildControllersRegistry childRegistry overlaysLayout = new CoordinatorLayout(getActivity()); } + + public void bindViews() { modalStack.setModalsLayout(modalsLayout); modalStack.setRootLayout(rootLayout); @@ -147,6 +151,7 @@ public void setRoot(final ViewController appearing, CommandListener commandLi final ViewController disappearing = previousRoot; root = appearing; root.setOverlay(new RootOverlay(getActivity(), contentLayout)); + root.setParentController(this); rootPresenter.setRoot(appearing, disappearing, defaultOptions, new CommandListenerAdapter(commandListener) { @Override public void onSuccess(String childId) { diff --git a/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/Presenter.java b/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/Presenter.java index fa6292eced8..5f52ff9854f 100644 --- a/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/Presenter.java +++ b/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/Presenter.java @@ -5,18 +5,17 @@ import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.LayerDrawable; -import android.os.Build; import android.view.View; +import android.view.ViewGroup; import android.view.ViewGroup.MarginLayoutParams; import android.view.Window; -import androidx.core.content.ContextCompat; - import com.reactnativenavigation.options.NavigationBarOptions; import com.reactnativenavigation.options.Options; import com.reactnativenavigation.options.OrientationOptions; import com.reactnativenavigation.options.StatusBarOptions; import com.reactnativenavigation.options.StatusBarOptions.TextColorScheme; +import com.reactnativenavigation.options.layout.LayoutInsets; import com.reactnativenavigation.options.params.Bool; import com.reactnativenavigation.utils.SystemUiUtils; import com.reactnativenavigation.viewcontrollers.parent.ParentController; @@ -25,6 +24,7 @@ public class Presenter { private final Activity activity; private Options defaultOptions; + public Presenter(Activity activity, Options defaultOptions) { this.activity = activity; this.defaultOptions = defaultOptions; @@ -40,9 +40,15 @@ public Options getDefaultOptions() { } public void mergeOptions(ViewController viewController, Options options) { - final Options withDefaults = viewController.resolveCurrentOptions().copy().mergeWith(options).withDefaultOptions(defaultOptions); + final Options withDefaults = viewController.resolveCurrentOptions().copy().mergeWith(options).withDefaultOptions(defaultOptions); mergeStatusBarOptions(viewController.getView(), withDefaults.statusBar); mergeNavigationBarOptions(withDefaults.navigationBar); + applyLayoutInsetsOnMostTopParent(viewController,withDefaults.layout.getInsets()); + } + + private void applyLayoutInsetsOnMostTopParent(ViewController viewController, LayoutInsets layoutInsets) { + final ViewController topMostParent = viewController.getTopMostParent(); + applyLayoutInsets(topMostParent.getView(), layoutInsets); } public void applyOptions(ViewController view, Options options) { @@ -65,6 +71,16 @@ private void applyOrientation(OrientationOptions options) { private void applyViewOptions(ViewController view, Options options) { applyBackgroundColor(view, options); applyTopMargin(view.getView(), options); + applyLayoutInsetsOnMostTopParent(view, options.layout.getInsets()); + } + + private void applyLayoutInsets(ViewGroup view, LayoutInsets layoutInsets) { + if ( view!=null && layoutInsets.hasValue()) { + view.setPadding(layoutInsets.getLeft() == null ? view.getPaddingLeft() : layoutInsets.getLeft(), + layoutInsets.getTop() == null ? view.getPaddingTop() : layoutInsets.getTop(), + layoutInsets.getRight() == null ?view.getPaddingRight() : layoutInsets.getRight(), + layoutInsets.getBottom() == null ? view.getPaddingBottom() : layoutInsets.getBottom()); + } } private void applyTopMargin(View view, Options options) { @@ -209,12 +225,12 @@ private void applyNavigationBarVisibility(NavigationBarOptions options) { } private void setNavigationBarBackgroundColor(NavigationBarOptions navigationBar) { - int navigationBarDefaultColor = SystemUiUtils.INSTANCE.getNavigationBarDefaultColor(); - navigationBarDefaultColor = navigationBarDefaultColor==-1?Color.BLACK:navigationBarDefaultColor; + int navigationBarDefaultColor = SystemUiUtils.INSTANCE.getNavigationBarDefaultColor(); + navigationBarDefaultColor = navigationBarDefaultColor == -1 ? Color.BLACK : navigationBarDefaultColor; if (navigationBar.backgroundColor.canApplyValue()) { int color = navigationBar.backgroundColor.get(navigationBarDefaultColor); SystemUiUtils.setNavigationBarBackgroundColor(activity.getWindow(), color, isColorLight(color)); - }else{ + } else { SystemUiUtils.setNavigationBarBackgroundColor(activity.getWindow(), navigationBarDefaultColor, isColorLight(navigationBarDefaultColor)); } diff --git a/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewController.java b/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewController.java index 6f8f206d94a..d3267d411b3 100644 --- a/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewController.java +++ b/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewController.java @@ -1,5 +1,8 @@ package com.reactnativenavigation.viewcontrollers.viewcontroller; +import static com.reactnativenavigation.utils.CollectionUtils.forEach; +import static com.reactnativenavigation.utils.ObjectUtils.perform; + import android.app.Activity; import android.content.res.Configuration; import android.view.View; @@ -31,9 +34,6 @@ import java.util.ArrayList; import java.util.List; -import static com.reactnativenavigation.utils.CollectionUtils.forEach; -import static com.reactnativenavigation.utils.ObjectUtils.perform; - public abstract class ViewController implements ViewTreeObserver.OnGlobalLayoutListener, ViewGroup.OnHierarchyChangeListener, BehaviourAdapter { @@ -157,6 +157,14 @@ public void mergeOptions(Options options) { } } + public ViewController getTopMostParent(){ + if(parentController!=null){ + return parentController.getTopMostParent(); + }else{ + return this; + } + } + @CallSuper public void applyOptions(Options options) { @@ -289,6 +297,7 @@ public void destroy() { if (view.getParent() instanceof ViewGroup) { ((ViewManager) view.getParent()).removeView(view); } + setParentController(null); view = null; isDestroyed = true; } diff --git a/lib/android/app/src/test/java/com/reactnativenavigation/mocks/Mocks.kt b/lib/android/app/src/test/java/com/reactnativenavigation/mocks/Mocks.kt index 70ee02e0192..647c81eb529 100644 --- a/lib/android/app/src/test/java/com/reactnativenavigation/mocks/Mocks.kt +++ b/lib/android/app/src/test/java/com/reactnativenavigation/mocks/Mocks.kt @@ -1,8 +1,8 @@ package com.reactnativenavigation.mocks import android.view.ViewGroup - import com.reactnativenavigation.options.Options +import com.reactnativenavigation.viewcontrollers.parent.ParentController import com.reactnativenavigation.viewcontrollers.viewcontroller.ViewController import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @@ -15,4 +15,17 @@ object Mocks { whenever(mock.view).thenReturn(view) return mock } + fun parentController(topMostParentController: ParentController<*>?=null):ParentController{ + val mock = mock>() + whenever(mock.topMostParent).thenReturn(topMostParentController?:mock) + return mock + } + + fun parentController(topMostParentController: ParentController<*>?=null,view:ViewGroup):ParentController{ + val mock = mock>() + whenever(mock.topMostParent).thenReturn(topMostParentController?:mock) + whenever(mock.view).thenReturn(view) + + return mock + } } \ No newline at end of file diff --git a/lib/android/app/src/test/java/com/reactnativenavigation/presentation/PresenterTest.java b/lib/android/app/src/test/java/com/reactnativenavigation/presentation/PresenterTest.java index a57ff3c86ec..b043ee19dfb 100644 --- a/lib/android/app/src/test/java/com/reactnativenavigation/presentation/PresenterTest.java +++ b/lib/android/app/src/test/java/com/reactnativenavigation/presentation/PresenterTest.java @@ -1,43 +1,52 @@ package com.reactnativenavigation.presentation; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + import android.app.Activity; -import android.view.View; import android.view.ViewGroup; import android.widget.FrameLayout; import com.reactnativenavigation.BaseTest; +import com.reactnativenavigation.mocks.Mocks; import com.reactnativenavigation.options.Options; +import com.reactnativenavigation.options.layout.LayoutInsets; import com.reactnativenavigation.options.params.Bool; import com.reactnativenavigation.utils.SystemUiUtils; +import com.reactnativenavigation.viewcontrollers.parent.ParentController; import com.reactnativenavigation.viewcontrollers.viewcontroller.Presenter; import com.reactnativenavigation.viewcontrollers.viewcontroller.ViewController; import org.junit.Test; import org.mockito.Mockito; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - public class PresenterTest extends BaseTest { private Presenter uut; private Activity activity; private ViewController controller; + private ViewController parentController; + private ViewGroup parentView; @Override public void beforeEach() { super.beforeEach(); activity = newActivity(); controller = mock(ViewController.class); + parentView = mock(ViewGroup.class); + parentController = Mocks.INSTANCE.parentController(null, parentView); + controller.setParentController((ParentController) parentController); + Mockito.when(controller.getTopMostParent()).thenReturn(parentController); uut = new Presenter(activity, Options.EMPTY); } @Test public void mergeStatusBarVisible_callsShowHide() { mockSystemUiUtils(1,1,(mockedStatic)->{ - ViewGroup spy = Mockito.spy(new FrameLayout(activity)); + ViewGroup spy = spy(new FrameLayout(activity)); Mockito.when(controller.getView()).thenReturn(spy); Mockito.when(controller.resolveCurrentOptions()).thenReturn(Options.EMPTY); Options options = new Options(); @@ -53,4 +62,37 @@ public void mergeStatusBarVisible_callsShowHide() { }); } + + @Test + public void shouldApplyInsetsOnTopMostParent(){ + final ViewGroup spy = Mockito.mock(ViewGroup.class); + Mockito.when(spy.getLayoutParams()).thenReturn(new ViewGroup.LayoutParams(0,0)); + Mockito.when(controller.getView()).thenReturn(spy); + Options options = new Options(); + options.layout.setInsets(new LayoutInsets( + 1,2,3,4 + )); + + uut.applyOptions(controller,options); + + verify(parentView).setPadding(2,1,4,3); + } + + @Test + public void shouldMergeInsetsOnTopMostParent(){ + final ViewGroup spy = Mockito.mock(ViewGroup.class); + Mockito.when(spy.getLayoutParams()).thenReturn(new ViewGroup.LayoutParams(0,0)); + Mockito.when(controller.getView()).thenReturn(spy); + Mockito.when(controller.resolveCurrentOptions()).thenReturn(Options.EMPTY); + + Options options = new Options(); + options.layout.setInsets(new LayoutInsets( + 1,2,3,4 + )); + + uut.mergeOptions(controller,options); + + verify(parentView).setPadding(2,1,4,3); + } + } diff --git a/lib/android/app/src/test/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabsControllerTest.kt b/lib/android/app/src/test/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabsControllerTest.kt index 1c137e6965c..9c70ff25aa0 100644 --- a/lib/android/app/src/test/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabsControllerTest.kt +++ b/lib/android/app/src/test/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabsControllerTest.kt @@ -5,11 +5,13 @@ import android.graphics.Color import android.view.Gravity import android.view.View import android.view.ViewGroup.MarginLayoutParams +import android.widget.FrameLayout import androidx.coordinatorlayout.widget.CoordinatorLayout import com.aurelhubert.ahbottomnavigation.AHBottomNavigation.TitleState import com.reactnativenavigation.BaseTest import com.reactnativenavigation.TestUtils import com.reactnativenavigation.mocks.ImageLoaderMock.mock +import com.reactnativenavigation.mocks.Mocks import com.reactnativenavigation.mocks.SimpleViewController import com.reactnativenavigation.mocks.TypefaceLoaderMock import com.reactnativenavigation.options.BottomTabsOptions @@ -24,7 +26,6 @@ import com.reactnativenavigation.utils.SystemUiUtils.saveStatusBarHeight import com.reactnativenavigation.viewcontrollers.bottomtabs.attacher.BottomTabsAttacher import com.reactnativenavigation.viewcontrollers.child.ChildControllersRegistry import com.reactnativenavigation.viewcontrollers.fakes.FakeParentController -import com.reactnativenavigation.viewcontrollers.parent.ParentController import com.reactnativenavigation.viewcontrollers.stack.StackController import com.reactnativenavigation.viewcontrollers.viewcontroller.Presenter import com.reactnativenavigation.viewcontrollers.viewcontroller.ViewController @@ -267,7 +268,7 @@ class BottomTabsControllerTest : BaseTest() { @Test fun applyChildOptions_bottomTabsOptionsAreClearedAfterApply() { - val parent = Mockito.mock(ParentController::class.java) + val parent = Mocks.parentController() uut.parentController = parent child1.options.bottomTabsOptions.backgroundColor = ThemeColour(Colour(Color.RED)) child1.onViewWillAppear() @@ -550,6 +551,7 @@ class BottomTabsControllerTest : BaseTest() { options: Options = initialOptions, defaultOptions: Options = initialOptions ): BottomTabsController { + val presenter1 = Presenter(activity, defaultOptions) return object : BottomTabsController( activity, tabs, @@ -558,11 +560,14 @@ class BottomTabsControllerTest : BaseTest() { imageLoaderMock, "uut", options, - Presenter(activity, defaultOptions), + presenter1, tabsAttacher, presenter, bottomTabPresenter ) { + override fun getTopMostParent(): ViewController<*> { + return Mocks.parentController(null, FrameLayout(activity)) + } override fun ensureViewIsCreated() { super.ensureViewIsCreated() uut.view.layout(0, 0, 1000, 1000) diff --git a/lib/android/app/src/test/java/com/reactnativenavigation/viewcontrollers/sidemenu/SideMenuControllerTest.java b/lib/android/app/src/test/java/com/reactnativenavigation/viewcontrollers/sidemenu/SideMenuControllerTest.java index e59e4783d32..540c304fe90 100644 --- a/lib/android/app/src/test/java/com/reactnativenavigation/viewcontrollers/sidemenu/SideMenuControllerTest.java +++ b/lib/android/app/src/test/java/com/reactnativenavigation/viewcontrollers/sidemenu/SideMenuControllerTest.java @@ -11,6 +11,7 @@ import android.view.Window; import com.reactnativenavigation.BaseTest; +import com.reactnativenavigation.mocks.Mocks; import com.reactnativenavigation.mocks.SimpleComponentViewController; import com.reactnativenavigation.options.Options; import com.reactnativenavigation.options.SideMenuOptions; @@ -73,7 +74,7 @@ public Options resolveCurrentOptions() { } }; uut.setCenterController(center); - parent = mock(ParentController.class); + parent = Mocks.INSTANCE.parentController(null); Mockito.when(parent.resolveChildOptions(uut)).thenReturn(Options.EMPTY); uut.setParentController(parent); } diff --git a/lib/android/app/src/test/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewControllerTest.java b/lib/android/app/src/test/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewControllerTest.java index 90c29e32e16..b59255230f6 100644 --- a/lib/android/app/src/test/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewControllerTest.java +++ b/lib/android/app/src/test/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewControllerTest.java @@ -11,6 +11,7 @@ import com.reactnativenavigation.BaseTest; import com.reactnativenavigation.TestUtils; +import com.reactnativenavigation.mocks.Mocks; import com.reactnativenavigation.mocks.SimpleViewController; import com.reactnativenavigation.options.Options; import com.reactnativenavigation.options.params.Bool; @@ -44,6 +45,7 @@ public class ViewControllerTest extends BaseTest { private Activity activity; private ChildControllersRegistry childRegistry; private YellowBoxDelegate yellowBoxDelegate; + private ParentController mockedParent; @Override public void beforeEach() { @@ -52,9 +54,22 @@ public void beforeEach() { activity = newActivity(); childRegistry = new ChildControllersRegistry(); uut = new SimpleViewController(activity, childRegistry, "uut", new Options()); - final ParentController parent = mock(ParentController.class); - uut.setParentController(parent); - Mockito.when(parent.resolveChildOptions(any())).thenReturn(Options.EMPTY); + mockedParent = Mocks.INSTANCE.parentController(null); + uut.setParentController(mockedParent); + Mockito.when(mockedParent.resolveChildOptions(any())).thenReturn(Options.EMPTY); + } + + @Test + public void topMostParent_shouldReturnSelfWhenNoParent(){ + assertThat(uut.getTopMostParent()).isEqualTo(mockedParent); + } + + @Test + public void topMostParent_shouldReturnRootParent(){ + final ParentController root = Mocks.INSTANCE.parentController(null); + final ParentController parent1 = Mocks.INSTANCE.parentController(root); + uut.setParentController(parent1); + assertThat(uut.getTopMostParent()).isEqualTo(root); } @Test diff --git a/lib/ios/RNNBasePresenter.m b/lib/ios/RNNBasePresenter.m index 12c7c2e14e4..86493a01fcc 100644 --- a/lib/ios/RNNBasePresenter.m +++ b/lib/ios/RNNBasePresenter.m @@ -58,6 +58,15 @@ - (void)applyOptionsOnViewDidLayoutSubviews:(RNNNavigationOptions *)options { } - (void)applyOptions:(RNNNavigationOptions *)options { + UIViewController *viewController = self.boundViewController; + RNNNavigationOptions *withDefault = [options withDefault:[self defaultOptions]]; + if (withDefault.layout.insets.hasValue) { + viewController.topMostViewController.additionalSafeAreaInsets = + UIEdgeInsetsMake([withDefault.layout.insets.top withDefault:0], + [withDefault.layout.insets.left withDefault:0], + [withDefault.layout.insets.bottom withDefault:0], + [withDefault.layout.insets.right withDefault:0]); + } } - (void)mergeOptions:(RNNNavigationOptions *)mergeOptions @@ -83,6 +92,14 @@ - (void)mergeOptions:(RNNNavigationOptions *)mergeOptions _prefersHomeIndicatorAutoHidden = mergeOptions.layout.autoHideHomeIndicator.get; [self.boundViewController setNeedsUpdateOfHomeIndicatorAutoHidden]; } + + if (mergeOptions.layout.insets.hasValue) { + self.boundViewController.topMostViewController.additionalSafeAreaInsets = + UIEdgeInsetsMake([withDefault.layout.insets.top withDefault:0], + [withDefault.layout.insets.left withDefault:0], + [withDefault.layout.insets.bottom withDefault:0], + [withDefault.layout.insets.right withDefault:0]); + } } - (void)renderComponents:(RNNNavigationOptions *)options diff --git a/lib/ios/RNNBottomTabsController.m b/lib/ios/RNNBottomTabsController.m index 5525008ba94..1a2b6bf7ac4 100644 --- a/lib/ios/RNNBottomTabsController.m +++ b/lib/ios/RNNBottomTabsController.m @@ -88,6 +88,7 @@ - (void)render { } - (void)viewDidLayoutSubviews { + [super viewDidLayoutSubviews]; [self.presenter viewDidLayoutSubviews]; [_dotIndicatorPresenter bottomTabsDidLayoutSubviews:self]; } diff --git a/lib/ios/RNNComponentViewController.m b/lib/ios/RNNComponentViewController.m index e45a43a216d..3ce96749962 100644 --- a/lib/ios/RNNComponentViewController.m +++ b/lib/ios/RNNComponentViewController.m @@ -111,6 +111,11 @@ - (void)viewSafeAreaInsetsDidChange { [self updateReactViewConstraints]; } +- (void)viewDidLayoutSubviews { + [super viewDidLayoutSubviews]; + [self.presenter applyOptionsOnViewDidLayoutSubviews:self.resolveOptions]; +} + - (void)updateReactViewConstraints { if (self.isViewLoaded && self.reactView) { [NSLayoutConstraint deactivateConstraints:_reactViewConstraints]; diff --git a/lib/ios/RNNLayoutOptions.h b/lib/ios/RNNLayoutOptions.h index e421fe2d064..1fc4a79b7cb 100644 --- a/lib/ios/RNNLayoutOptions.h +++ b/lib/ios/RNNLayoutOptions.h @@ -1,5 +1,5 @@ +#import "RNNInsetsOptions.h" #import "RNNOptions.h" - @interface RNNLayoutOptions : RNNOptions @property(nonatomic, strong) Color *backgroundColor; @@ -7,6 +7,7 @@ @property(nonatomic, strong) Text *direction; @property(nonatomic, strong) id orientation; @property(nonatomic, strong) Bool *autoHideHomeIndicator; +@property(nonatomic, strong) RNNInsetsOptions *insets; - (UIInterfaceOrientationMask)supportedOrientations; diff --git a/lib/ios/RNNLayoutOptions.m b/lib/ios/RNNLayoutOptions.m index 3e09f88eeb9..11de293d51c 100644 --- a/lib/ios/RNNLayoutOptions.m +++ b/lib/ios/RNNLayoutOptions.m @@ -11,6 +11,7 @@ - (instancetype)initWithDict:(NSDictionary *)dict { self.direction = [TextParser parse:dict key:@"direction"]; self.orientation = dict[@"orientation"]; self.autoHideHomeIndicator = [BoolParser parse:dict key:@"autoHideHomeIndicator"]; + self.insets = [[RNNInsetsOptions alloc] initWithDict:dict[@"insets"]]; return self; } @@ -25,6 +26,9 @@ - (void)mergeOptions:(RNNLayoutOptions *)options { self.orientation = options.orientation; if (options.autoHideHomeIndicator) self.autoHideHomeIndicator = options.autoHideHomeIndicator; + if (options.insets.hasValue) { + self.insets = options.insets; + } } - (UIInterfaceOrientationMask)supportedOrientations { diff --git a/lib/ios/RNNStackPresenter.m b/lib/ios/RNNStackPresenter.m index 9de55100a5d..5f4218e5fd7 100644 --- a/lib/ios/RNNStackPresenter.m +++ b/lib/ios/RNNStackPresenter.m @@ -88,6 +88,7 @@ - (void)applyOptions:(RNNNavigationOptions *)options { } - (void)applyOptionsOnViewDidLayoutSubviews:(RNNNavigationOptions *)options { + [super applyOptionsOnViewDidLayoutSubviews:options]; RNNNavigationOptions *withDefault = [options withDefault:[self defaultOptions]]; if (withDefault.topBar.background.component.name.hasValue) { [self presentBackgroundComponent]; diff --git a/lib/src/interfaces/Options.ts b/lib/src/interfaces/Options.ts index d98a0c499e1..70239dd03c7 100644 --- a/lib/src/interfaces/Options.ts +++ b/lib/src/interfaces/Options.ts @@ -180,6 +180,18 @@ export interface OptionsLayout { * #### (iOS specific) */ autoHideHomeIndicator?: boolean; + + /** + * Add insets to the top layout + */ + insets?: Insets; + + /** + * Resizes the layout when keyboard is visible + * @default true + * #### (Android specific) + */ + adjustResize?: boolean; } export enum OptionsModalPresentationStyle { diff --git a/package.json b/package.json index 4d8a7f245f9..038aa7c85b3 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,8 @@ "tslib": "1.9.3" }, "devDependencies": { + "pixelmatch": "^5.2.1", + "pngjs": "^6.0.0", "@babel/plugin-proposal-export-default-from": "7.10.1", "@babel/plugin-proposal-export-namespace-from": "7.10.1", "@babel/types": "7.15.6", @@ -216,4 +218,4 @@ } } } -} \ No newline at end of file +} diff --git a/playground/src/components/Root.tsx b/playground/src/components/Root.tsx index e2855b64f5b..35ef650e610 100644 --- a/playground/src/components/Root.tsx +++ b/playground/src/components/Root.tsx @@ -12,6 +12,7 @@ import { } from 'react-native'; import { Keyboard } from 'react-native-ui-lib'; import flags from '../flags'; +import testIDs from '../testIDs'; const KeyboardAwareInsetsView = Keyboard.KeyboardAwareInsetsView; const { showTextInputToTestKeyboardInteraction } = flags; @@ -53,7 +54,10 @@ const Footer: React.FC = ({ componentId, footer }) => { {footer && {footer}} {/* Rendering component id. */} - {`this.props.componentId = ${componentId}`} + {`this.props.componentId = ${componentId}`} ); }; @@ -82,6 +86,7 @@ const styles = StyleSheet.create({ fontSize: 10, color: '#888', marginTop: 10, + backgroundColor: 'yellow', }, }); diff --git a/playground/src/screens/OverlayBanner.tsx b/playground/src/screens/OverlayBanner.tsx new file mode 100644 index 00000000000..d4c67d772e7 --- /dev/null +++ b/playground/src/screens/OverlayBanner.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { SafeAreaView, StyleSheet, TextStyle, View, ViewStyle } from 'react-native'; +import { NavigationComponent, NavigationComponentProps, Options } from 'react-native-navigation'; +import testIDs from '../testIDs'; +import Button from '../components/Button'; +import Navigation from '../services/Navigation'; + +interface Props extends NavigationComponentProps { + incrementDismissedOverlays: any; +} + +const { BANNER_OVERLAY } = testIDs; +let adjustResize = true; +export default class OverlayBanner extends NavigationComponent { + static options(): Options { + return { + layout: { + adjustResize: adjustResize, + }, + }; + } + + render() { + return ( + + +