diff --git a/.github/workflows/publish_bottomnavigation_ado.yml b/.github/workflows/publish_bottomnavigation_ado.yml
index db45c2ff..c109de7a 100644
--- a/.github/workflows/publish_bottomnavigation_ado.yml
+++ b/.github/workflows/publish_bottomnavigation_ado.yml
@@ -1,7 +1,7 @@
# This workflow will build a Java project with Gradle
# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-gradle
-name: publish-recyclerview-ado
+name: publish-bottomnavigation-ado
# This workflow builds and publishes in ADO the BottomNavigation artifact.
on:
diff --git a/.github/workflows/publish_navigationrail_ado.yml b/.github/workflows/publish_navigationrail_ado.yml
new file mode 100644
index 00000000..54d24f65
--- /dev/null
+++ b/.github/workflows/publish_navigationrail_ado.yml
@@ -0,0 +1,73 @@
+# This workflow will build a Java project with Gradle
+# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-gradle
+
+name: publish-navigationrail-ado
+# This workflow builds and publishes in ADO the NavigationRail artifact.
+
+on:
+ push:
+ tags:
+ - 'navigationrail*'
+
+ workflow_dispatch:
+ inputs:
+ name:
+ description: 'Triggers publication to ADO - NavigationRail'
+ home:
+ description: 'location'
+ required: false
+
+jobs:
+ publish-NavigationRail-ADO:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+ - name: Set up JDK 11
+ uses: actions/setup-java@v1
+ with:
+ java-version: 11
+
+ - name: Cache Gradle packages
+ uses: actions/cache@v2
+ with:
+ path: ~/.gradle/caches
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }}
+ restore-keys: ${{ runner.os }}-gradle
+
+ # Base64 decodes and pipes the GPG key content into the secret file
+ - name: Prepare environment
+ env:
+ SIGNING_SECRET_KEY: ${{ secrets.SIGNING_SECRET_KEY }}
+ SIGNING_SECRET_FILE: ${{ secrets.SIGNING_SECRET_FILE }}
+ run: |
+ git fetch --unshallow
+ sudo bash -c "echo '$SIGNING_SECRET_KEY' | base64 -d > '$SIGNING_SECRET_FILE'"
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: clean
+ run: ./gradlew :navigationrail:navigationrail:clean
+
+ # Builds the release artifacts of the library
+ - name: Release build
+ run: ./gradlew :navigationrail:navigationrail:assembleRelease
+
+ # Generates other artifacts
+ - name: Source jar
+ run: ./gradlew :navigationrail:navigationrail:androidSourcesJar
+
+ # Generates docs artifact
+ - name: Docs jar
+ run: ./gradlew :navigationrail:navigationrail:dokkaHtmlJar
+
+ # Runs upload to ADO
+ - name: Publish to ADO
+ run: ./gradlew :navigationrail:navigationrail:publishSurfaceDuoSDKPublicationToADORepository --max-workers 1
+ env:
+ ADO_URL: ${{ secrets.ADO_URL }}
+ ADO_USER: ${{ secrets.ADO_USER }}
+ ADO_PASSWD: ${{ secrets.ADO_PASSWD }}
+ SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }}
+ SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }}
+ SIGNING_SECRET_FILE: ${{ secrets.SIGNING_SECRET_FILE }}
diff --git a/.github/workflows/publish_navigationrail_mavencentral.yml b/.github/workflows/publish_navigationrail_mavencentral.yml
new file mode 100644
index 00000000..772a2498
--- /dev/null
+++ b/.github/workflows/publish_navigationrail_mavencentral.yml
@@ -0,0 +1,74 @@
+# This workflow will build a Java project with Gradle
+# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-gradle
+
+name: publish-navigationrail-mavencentral
+# This workflow builds and publishes in Maven Central the NavigationRail artifact.
+# Although the final publishing step has to be done manually in Sonatype site.
+
+on:
+ push:
+ tags:
+ - 'navigationrail*'
+
+ workflow_dispatch:
+ inputs:
+ name:
+ description: 'Triggers publication to MavenCentral - NavigationRail'
+ home:
+ description: 'location'
+ required: false
+
+jobs:
+ publish-NavigationRail-MavenCentral:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+ - name: Set up JDK 11
+ uses: actions/setup-java@v1
+ with:
+ java-version: 11
+
+ - name: Cache Gradle packages
+ uses: actions/cache@v2
+ with:
+ path: ~/.gradle/caches
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }}
+ restore-keys: ${{ runner.os }}-gradle
+
+ # Base64 decodes and pipes the GPG key content into the secret file
+ - name: Prepare environment
+ env:
+ SIGNING_SECRET_KEY: ${{ secrets.SIGNING_SECRET_KEY }}
+ SIGNING_SECRET_FILE: ${{ secrets.SIGNING_SECRET_FILE }}
+ run: |
+ git fetch --unshallow
+ sudo bash -c "echo '$SIGNING_SECRET_KEY' | base64 -d > '$SIGNING_SECRET_FILE'"
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: clean
+ run: ./gradlew :navigationrail:navigationrail:clean
+
+ # Builds the release artifacts of the library
+ - name: Release build
+ run: ./gradlew :navigationrail:navigationrail:assembleRelease
+
+ # Generates other artifacts
+ - name: Source jar
+ run: ./gradlew :navigationrail:navigationrail:androidSourcesJar
+
+ # Generates docs artifact
+ - name: Docs jar
+ run: ./gradlew :navigationrail:navigationrail:dokkaHtmlJar
+
+ # Runs upload to MavenCentral (final release step will be manually done in Sonatype site)
+ - name: Publish to MavenCentral
+ run: ./gradlew :navigationrail:navigationrail:publishSurfaceDuoSDKPublicationToMavencentralRepository --max-workers 1
+ env:
+ MAVEN_USER: ${{ secrets.MAVEN_USER }}
+ MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
+ SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }}
+ SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }}
+ SIGNING_SECRET_FILE: ${{ secrets.SIGNING_SECRET_FILE }}
+
diff --git a/dependencies.gradle b/dependencies.gradle
index 1310f126..b390f2ab 100644
--- a/dependencies.gradle
+++ b/dependencies.gradle
@@ -25,10 +25,11 @@ ext {
tabsVersionCode = 4
recyclerviewVersionCode = 6
inkSDKVersionCode = 7
- wmUtilsVersionCode = 4
+ wmUtilsVersionCode = 5
navigationVersionCode = 2
testingVersionCode = 4
snackbarVersionCode = 2
+ navigationRailVersionCode = 1
// SurfaceDuo SDK libraries version name:
// If you want to publish a new version, bump the specific line(s)
@@ -42,10 +43,11 @@ ext {
tabsVersionName = '1.0.0-beta5'
recyclerviewVersionName = '1.0.0-beta6'
inkSDKVersionName = "1.0.0-alpha5"
- wmUtilsVersionName = "1.0.0-beta4"
+ wmUtilsVersionName = "1.0.0-beta5"
navigationVersionName = "1.0.0-alpha3"
testingVersionName = "1.0.0-alpha4"
snackbarVersionName = "1.0.0-alpha2"
+ navigationRailVersionName = '1.0.0-alpha1'
// ----------------------------------
// Config Android sdk, plugins and libraries version
@@ -108,8 +110,11 @@ ext {
//Material Design
materialVersion = "1.3.0"
+ // Can not migrate all libraries to 1.6.0 yet. This version will be used only by the NavigationRailView
+ materialVersion160 = "1.6.0"
materialDependencies = [
- material: "com.google.android.material:material:$materialVersion"
+ material : "com.google.android.material:material:$materialVersion",
+ material160: "com.google.android.material:material:$materialVersion160"
]
googleTruthVersion = "1.1.2"
diff --git a/navigationrail/navigationrail/.gitignore b/navigationrail/navigationrail/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/navigationrail/navigationrail/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/navigationrail/navigationrail/build.gradle b/navigationrail/navigationrail/build.gradle
new file mode 100644
index 00000000..f1f1e705
--- /dev/null
+++ b/navigationrail/navigationrail/build.gradle
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License.
+ */
+
+plugins {
+ id 'com.android.library'
+ id 'kotlin-android'
+}
+
+ext {
+ PUBLISH_GROUP_ID = 'com.microsoft.device.dualscreen'
+ PUBLISH_ARTIFACT_ID = 'navigationrail'
+ LIBRARY_DESCRIPTION = 'Library that implements the NavigationRail specifications and adapts automatically to the different foldable configurations'
+ LIBRARY_VERSION = rootProject.ext.navigationRailVersionName
+}
+apply from: "${rootProject.projectDir}/publishing.gradle"
+
+android {
+ compileSdkVersion rootProject.ext.compileSdkVersion
+ buildToolsVersion rootProject.ext.buildToolsVersion
+
+ defaultConfig {
+ minSdkVersion rootProject.ext.minSdkVersion
+ targetSdkVersion rootProject.ext.targetSdkVersion
+
+ versionCode rootProject.ext.navigationRailVersionCode
+ versionName rootProject.ext.navigationRailVersionName
+
+ testInstrumentationRunner rootProject.ext.config.testInstrumentationRunner
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+}
+
+dependencies {
+ api project(':utils:wm-utils')
+
+ implementation kotlinDependencies.kotlinStdlib
+ implementation androidxDependencies.lifecycleRuntimeKtx
+ api materialDependencies.material
+ api androidxDependencies.windowManager
+
+ api materialDependencies.material160
+
+ testImplementation testDependencies.junit
+
+ androidTestImplementation project(':utils:test-utils')
+ androidTestImplementation commonDependencies.mockitoDexMaker
+ androidTestImplementation commonDependencies.mockitoCore
+ androidTestImplementation instrumentationTestDependencies.junit
+ androidTestImplementation instrumentationTestDependencies.testRules
+ androidTestImplementation instrumentationTestDependencies.espressoCore
+ androidTestImplementation instrumentationTestDependencies.uiAutomator
+}
\ No newline at end of file
diff --git a/navigationrail/navigationrail/src/main/AndroidManifest.xml b/navigationrail/navigationrail/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..0448bd09
--- /dev/null
+++ b/navigationrail/navigationrail/src/main/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
\ No newline at end of file
diff --git a/navigationrail/navigationrail/src/main/java/com/microsoft/device/dualscreen/navigationrail/NavigationRailView.kt b/navigationrail/navigationrail/src/main/java/com/microsoft/device/dualscreen/navigationrail/NavigationRailView.kt
new file mode 100644
index 00000000..0308f94c
--- /dev/null
+++ b/navigationrail/navigationrail/src/main/java/com/microsoft/device/dualscreen/navigationrail/NavigationRailView.kt
@@ -0,0 +1,595 @@
+/*
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License.
+ */
+
+package com.microsoft.device.dualscreen.navigationrail
+
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.content.Context
+import android.graphics.Point
+import android.graphics.Rect
+import android.os.Build
+import android.os.Parcel
+import android.os.Parcelable
+import android.util.AttributeSet
+import android.view.Gravity
+import android.view.MotionEvent
+import android.view.View
+import android.view.animation.AccelerateDecelerateInterpolator
+import android.view.animation.BaseInterpolator
+import androidx.activity.ComponentActivity
+import androidx.core.view.animation.PathInterpolatorCompat
+import androidx.customview.view.AbsSavedState
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.transition.ChangeBounds
+import androidx.transition.Transition
+import androidx.transition.TransitionManager
+import androidx.window.layout.WindowInfoTracker
+import androidx.window.layout.WindowLayoutInfo
+import androidx.window.layout.WindowMetricsCalculator
+import com.google.android.material.navigationrail.NavigationRailMenuView
+import com.google.android.material.navigationrail.NavigationRailView
+import com.microsoft.device.dualscreen.utils.wm.DisplayPosition
+import com.microsoft.device.dualscreen.utils.wm.OnVerticalSwipeListener
+import com.microsoft.device.dualscreen.utils.wm.ScreenMode
+import com.microsoft.device.dualscreen.utils.wm.extractFoldingFeatureRect
+import com.microsoft.device.dualscreen.utils.wm.getWindowVisibleDisplayFrame
+import com.microsoft.device.dualscreen.utils.wm.isFoldingFeatureHorizontal
+import com.microsoft.device.dualscreen.utils.wm.isInDualMode
+import com.microsoft.device.dualscreen.utils.wm.isSeparating
+import com.microsoft.device.dualscreen.utils.wm.locationOnScreen
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.launch
+
+private const val MARGIN_LOWERING_FACTOR = 0.95
+
+/**
+ * A sub class of the [NavigationRailView] that can position its children in different ways when the application is spanned on both screens.
+ * Using the [arrangeButtons] and [setMenuGravity] the children can be split in any way between the two screens.
+ * Animations can be used when changing the arrangement of the buttons on the two screen.
+ */
+class NavigationRailView : NavigationRailView {
+ constructor(context: Context) : super(context) {
+ this.registerWindowInfoFlow()
+ }
+
+ constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
+ this.registerWindowInfoFlow()
+ extractAttributes(context, attrs)
+ }
+
+ constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
+ context,
+ attrs,
+ defStyleAttr
+ ) {
+ this.registerWindowInfoFlow()
+ extractAttributes(context, attrs)
+ }
+
+ private var screenMode = ScreenMode.DUAL_SCREEN
+ private var job: Job? = null
+ private var windowLayoutInfo: WindowLayoutInfo? = null
+
+ private var topBtnCount: Int = -1
+ private var bottomBtnCount: Int = -1
+
+ private fun normalizeFoldingFeatureRectForView(): Rect {
+ return windowLayoutInfo.extractFoldingFeatureRect().apply {
+ offset(-locationOnScreen.x, 0)
+ }
+ }
+
+ private fun registerWindowInfoFlow() {
+ val activity = (context as? ComponentActivity)
+ ?: throw RuntimeException("Context must implement androidx.activity.ComponentActivity!")
+ job = activity.lifecycleScope.launch(Dispatchers.Main) {
+ activity.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
+ WindowInfoTracker.getOrCreate(activity)
+ .windowLayoutInfo(activity)
+ .collect { info ->
+ windowLayoutInfo = info
+ onInfoLayoutChanged()
+ }
+ }
+ }
+ }
+
+ override fun onDetachedFromWindow() {
+ super.onDetachedFromWindow()
+ job?.cancel()
+ }
+
+ private fun onInfoLayoutChanged() {
+ setScreenParameters()
+
+ val changeBounds: Transition = ChangeBounds()
+ changeBounds.duration = 300L
+ changeBounds.interpolator = PathInterpolatorCompat.create(0.2f, 0f, 0f, 1f)
+ TransitionManager.beginDelayedTransition(this@NavigationRailView, changeBounds)
+ requestLayout()
+ }
+
+ private var onSwipeListener: OnVerticalSwipeListener =
+ object : OnVerticalSwipeListener(context) {
+ override fun onSwipeTop() {
+ super.onSwipeTop()
+ if (allowFlingGesture) {
+ menuGravity = Gravity.TOP
+ }
+ }
+
+ override fun onSwipeBottom() {
+ super.onSwipeBottom()
+ if (allowFlingGesture) {
+ menuGravity = Gravity.BOTTOM
+ }
+ }
+ }
+
+ /**
+ * Use an animation to move the buttons when the menu gravity is changed or [arrangeButtons] is called.
+ * By default the [AccelerateDecelerateInterpolator] is used.
+ */
+ var useAnimation: Boolean = true
+
+ /**
+ * Set the interpolator for the animation when menu gravity is changed or [arrangeButtons] is called.
+ * By default the [AccelerateDecelerateInterpolator] is used.
+ */
+ var animationInterpolator: BaseInterpolator = AccelerateDecelerateInterpolator()
+
+ /**
+ * Allows the buttons to be moved to [DisplayPosition.START] or [DisplayPosition.END] with a swipe gesture.
+ */
+ var allowFlingGesture: Boolean = true
+
+ private fun extractAttributes(context: Context, attrs: AttributeSet?) {
+ val styledAttributes =
+ context.theme.obtainStyledAttributes(
+ attrs,
+ R.styleable.ScreenManagerAttrs,
+ 0,
+ 0
+ )
+ try {
+ screenMode = ScreenMode.fromId(
+ styledAttributes.getResourceId(
+ R.styleable.ScreenManagerAttrs_tools_application_mode,
+ ScreenMode.DUAL_SCREEN.ordinal
+ )
+ )
+ useAnimation =
+ styledAttributes.getBoolean(
+ R.styleable.ScreenManagerAttrs_useAnimation,
+ useAnimation
+ )
+ allowFlingGesture =
+ styledAttributes.getBoolean(
+ R.styleable.ScreenManagerAttrs_allowFlingGesture,
+ allowFlingGesture
+ )
+ } finally {
+ styledAttributes.recycle()
+ }
+ }
+
+ private var topScreenHeight = -1
+ private var bottomScreenHeight = -1
+ private var appWindowFrameHeight = -1
+ private var hingeHeight = -1
+ private var statusBarHeight = -1
+
+ private var screenSize = Point()
+ private var appWindowPosition = Rect()
+ private var hingePosition = Rect()
+ private var shouldRedrawMenu = true
+
+ private fun setScreenParameters() {
+ if (!isIntersectingHorizontalHinge()) {
+ return
+ }
+ context.getWindowVisibleDisplayFrame().let { windowRect ->
+ appWindowPosition = windowRect
+ appWindowFrameHeight = windowRect.height()
+
+ normalizeFoldingFeatureRectForView().let { hingeRect ->
+ hingePosition = hingeRect
+
+ val windowBounds = WindowMetricsCalculator.getOrCreate()
+ .computeCurrentWindowMetrics(context as Activity).bounds
+ screenSize = Point(windowBounds.width(), windowBounds.height())
+
+ statusBarHeight = screenSize.y - appWindowFrameHeight
+ hingeHeight = hingeRect.height()
+ topScreenHeight = hingeRect.top - windowRect.top
+ bottomScreenHeight = windowRect.bottom - hingeRect.bottom
+ shouldRedrawMenu = true
+ }
+ }
+ }
+
+ private fun isIntersectingHorizontalHinge(): Boolean {
+ normalizeFoldingFeatureRectForView().let {
+ return windowLayoutInfo.isFoldingFeatureHorizontal() &&
+ (this.absY() + this.height > it.bottom)
+ }
+ }
+
+ /**
+ * Determines if the buttons should be split to avoid overlapping over the foldable feature.
+ */
+ private fun shouldSplitButtons(): Boolean {
+ return windowLayoutInfo.isInDualMode() &&
+ windowLayoutInfo.isFoldingFeatureHorizontal() &&
+ windowLayoutInfo.isSeparating() &&
+ isIntersectingHorizontalHinge()
+ }
+
+ /**
+ * Sets the menu gravity and splits the menu buttons to avoid overlapping the folding feature
+ */
+ override fun setMenuGravity(gravity: Int) {
+ super.setMenuGravity(gravity)
+ topBtnCount = -1
+ bottomBtnCount = -1
+ shouldRedrawMenu = true
+ requestLayout()
+ }
+
+ /**
+ * When the application is in spanned mode the buttons can be split between the screens.
+ * @param startBtnCount - how many buttons should be positioned on the top screen
+ * @param endBtnCount - how many buttons should be positioned on the bottom screen
+ */
+ fun arrangeButtons(startBtnCount: Int, endBtnCount: Int) {
+ getChildMenu()?.let { child ->
+ if (!shouldSplitButtons()) {
+ if (child.doesChildCountMatch(startBtnCount, endBtnCount)) {
+ syncBtnCount(startBtnCount, endBtnCount)
+ }
+ return
+ }
+
+ shouldRedrawMenu = true
+ menuGravity = Gravity.CENTER
+ syncBtnCount(startBtnCount, endBtnCount)
+ requestLayout()
+ }
+ }
+
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
+ if (shouldRedrawMenu) {
+ super.onLayout(changed, left, top, right, bottom)
+ getChildMenu()?.let { childMenu ->
+ if (!shouldSplitButtons()) {
+ return
+ }
+
+ val childMenuTop = calculateMenuTopMargin()
+ childMenu.layout(left, childMenuTop, right, bottom)
+ childMenu.positionButtonsByGravity()
+ shouldRedrawMenu = !shouldRedrawMenu
+ }
+ }
+ }
+
+ /**
+ * Positions the buttons inside the [NavigationRailMenuView] depending on the selected gravity
+ * and the foldable feature.
+ */
+ private fun NavigationRailMenuView.positionButtonsByGravity() {
+ when (getGravity()) {
+ Gravity.CENTER_VERTICAL -> {
+ positionButtonsInCenter()
+ }
+ Gravity.BOTTOM -> {
+ positionButtonsOnBottom()
+ }
+ Gravity.TOP -> {
+ positionButtonsOnTop()
+ }
+ }
+ }
+
+ private fun NavigationRailMenuView.positionButtonsOnTop() {
+ val childMenuTop = calculateMenuTopMargin()
+ val defaultChildHeight = getChildAt(0).measuredHeight
+ val availableHeightOnTopScreen = topScreenHeight - childMenuTop
+ val skipAnimation = shouldSkipAnimation(this)
+
+ // If the gravity is [Gravity.TOP] check if the buttons can fit
+ // in a single screen by reducing the margins between them.
+ if (defaultChildHeight * childCount * MARGIN_LOWERING_FACTOR <= availableHeightOnTopScreen &&
+ defaultChildHeight * childCount > availableHeightOnTopScreen
+ ) {
+ for (i in 0 until childCount) {
+ val newChildHeight = availableHeightOnTopScreen / childCount
+ val childTop = i * newChildHeight
+ setButtonPosition(getChildAt(i), childTop, skipAnimation)
+ }
+ return
+ } else {
+ // If the buttons can't fit on the top screen, move some on the bottom screen.
+ var topStartingPosition = getChildAt(0).top
+ val childrenAboveHinge =
+ (availableHeightOnTopScreen / defaultChildHeight).coerceAtMost(childCount)
+
+ for (i in 0 until childrenAboveHinge) {
+ val childTop = i * defaultChildHeight + topStartingPosition
+
+ val child = getChildAt(i)
+ child.layout(child.left, 0, child.right, defaultChildHeight)
+ setButtonPosition(child, childTop, skipAnimation)
+ }
+
+ if (childrenAboveHinge < childCount) {
+ topStartingPosition = availableHeightOnTopScreen + hingeHeight
+
+ for (i in 0 until childCount - childrenAboveHinge) {
+ val childTop =
+ +i * defaultChildHeight + topStartingPosition
+ val child = getChildAt(i + childrenAboveHinge)
+ child.layout(child.left, 0, child.right, 0 + defaultChildHeight)
+
+ setButtonPosition(child, childTop, skipAnimation)
+ }
+ }
+ }
+ }
+
+ private fun NavigationRailMenuView.positionButtonsOnBottom() {
+ val availableHeightOnBottomScreen = appWindowPosition.bottom - hingePosition.bottom
+ val defaultChildHeight = getChildAt(0).measuredHeight
+ val skipAnimation = shouldSkipAnimation(this)
+
+ val startingPosition = this.height - availableHeightOnBottomScreen
+ val newChildHeight =
+ (availableHeightOnBottomScreen / childCount).coerceAtMost(defaultChildHeight)
+ for (i in 0 until childCount) {
+ val childTop = i * newChildHeight + startingPosition
+
+ val child = getChildAt(i)
+ child.layout(child.left, 0, child.right, 0 + defaultChildHeight)
+ setButtonPosition(child, childTop, skipAnimation)
+ }
+ return
+ }
+
+ private fun NavigationRailMenuView.positionButtonsInCenter() {
+ if (topBtnCount == -1 || bottomBtnCount == -1) {
+ topBtnCount = childCount / 2 + childCount % 2
+ bottomBtnCount = childCount / 2
+ }
+
+ val buttonsCount = topBtnCount + bottomBtnCount
+ if (buttonsCount == 0) {
+ return
+ }
+
+ if (topBtnCount == 0) {
+ positionButtonsOnBottom()
+ return
+ }
+ if (bottomBtnCount == 0) {
+ positionButtonsOnTop()
+ return
+ }
+
+ val childMenuTop = calculateMenuTopMargin()
+ val defaultChildHeight = getChildAt(0).measuredHeight
+ val availableHeightOnTopScreen = topScreenHeight - childMenuTop
+ val skipAnimation = shouldSkipAnimation(this)
+
+ var btnMargin = 0
+
+ // Calculate a negative button margin if there is not enough space on the top
+ if (defaultChildHeight * topBtnCount > availableHeightOnTopScreen) {
+ btnMargin =
+ (availableHeightOnTopScreen - defaultChildHeight * topBtnCount) / topBtnCount
+ }
+
+ val topStartingPosition =
+ availableHeightOnTopScreen - topBtnCount * (defaultChildHeight + btnMargin)
+
+ for (i in 0 until topBtnCount) {
+ val childTop = topStartingPosition + i * (defaultChildHeight + btnMargin)
+ val child = getChildAt(i)
+ child.layout(child.left, 0, child.right, 0 + defaultChildHeight)
+ setButtonPosition(child, childTop, skipAnimation)
+ }
+
+ val availableHeightOnBottomScreen = appWindowPosition.bottom - hingePosition.bottom
+ val bottomStartingPosition = this.height - availableHeightOnBottomScreen
+
+ // Calculate a negative button margin if there is not enough space on the bottom
+ if (defaultChildHeight * bottomBtnCount > availableHeightOnBottomScreen) {
+ btnMargin =
+ (availableHeightOnBottomScreen - defaultChildHeight * bottomBtnCount) / bottomBtnCount
+ }
+
+ for (i in topBtnCount until buttonsCount) {
+ val childTop = bottomStartingPosition +
+ (i - topBtnCount) * (defaultChildHeight + btnMargin)
+ val child = getChildAt(i)
+ child.layout(child.left, 0, child.right, 0 + defaultChildHeight)
+ setButtonPosition(child, childTop, skipAnimation)
+ }
+ }
+
+ /**
+ * Sets the position inside the [NavigationRailMenuView] and triggers the translation animations.
+ */
+ private fun setButtonPosition(
+ child: View,
+ childTop: Int,
+ skipAnimation: Boolean
+ ) {
+ if (skipAnimation || !useAnimation || Build.VERSION.SDK_INT <= Build.VERSION_CODES.LOLLIPOP) {
+ child.translationY = childTop.toFloat()
+ } else {
+ child.animate()
+ .setInterpolator(animationInterpolator)
+ .translationY(childTop.toFloat())
+ }
+ }
+
+ /**
+ * Returns the margin of the [NavigationRailMenuView] from the top of it's parent.
+ * This is useful when the [NavigationRailView] contains a header view.
+ */
+ private fun calculateMenuTopMargin(): Int {
+ return headerView?.let {
+ val topMargin =
+ resources.getDimensionPixelSize(com.google.android.material.R.dimen.mtrl_navigation_rail_margin)
+ it.bottom + topMargin
+ } ?: 0
+ }
+
+ private fun getGravity() = menuGravity and Gravity.VERTICAL_GRAVITY_MASK
+
+ private fun getChildMenu(): NavigationRailMenuView? {
+ for (i in 0..childCount) {
+ val child = getChildAt(i)
+ if (child is NavigationRailMenuView) {
+ return child
+ }
+ }
+ return null
+ }
+
+ /**
+ * Checks if the newly requested positioning for the buttons matches the existing one.
+ */
+ private fun NavigationRailMenuView.doesChildCountMatch(
+ startBtnCount: Int,
+ endBtnCount: Int
+ ): Boolean {
+ return startBtnCount >= 0 && endBtnCount >= 0 && childCount == startBtnCount + endBtnCount
+ }
+
+ /**
+ * Skip the animation on the first layout pass.
+ */
+ private fun shouldSkipAnimation(menu: NavigationRailMenuView): Boolean {
+ for (i in 0 until menu.childCount) {
+ if (menu.getChildAt(i).left != 0) {
+ return true
+ }
+ }
+ return false
+ }
+
+ /**
+ * Synchronize the [startBtnCount] and [endBtnCount].
+ */
+ private fun syncBtnCount(startBtnCount: Int, endBtnCount: Int) {
+ this.topBtnCount = startBtnCount
+ this.bottomBtnCount = endBtnCount
+ }
+
+ override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
+ if (!allowFlingGesture) {
+ return super.onInterceptTouchEvent(ev)
+ }
+
+ onSwipeListener.onTouchEvent(ev)
+
+ if (onSwipeListener.onInterceptTouchEvent(ev)) {
+ return true
+ }
+ return super.onInterceptTouchEvent(ev)
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ override fun onTouchEvent(ev: MotionEvent?): Boolean {
+ if (!allowFlingGesture) {
+ return super.onTouchEvent(ev)
+ }
+ return onSwipeListener.onTouchEvent(ev)
+ }
+
+ override fun onSaveInstanceState(): Parcelable {
+ val superState: Parcelable = super.onSaveInstanceState()
+ val state = SavedState(superState)
+ state.useAnimation = this.useAnimation
+ state.allowFlingGesture = this.allowFlingGesture
+ state.menuGravity = getGravity()
+ state.topBtnCount = this.topBtnCount
+ state.bottomBtnCount = this.bottomBtnCount
+ return state
+ }
+
+ override fun onRestoreInstanceState(state: Parcelable?) {
+ when (state) {
+ is SavedState -> {
+ super.onRestoreInstanceState(state.superState)
+ this.useAnimation = state.useAnimation
+ this.allowFlingGesture = state.allowFlingGesture
+ menuGravity = state.menuGravity
+ this.topBtnCount = state.topBtnCount
+ this.bottomBtnCount = state.bottomBtnCount
+ }
+ else -> {
+ super.onRestoreInstanceState(state)
+ }
+ }
+ }
+
+ internal class SavedState : AbsSavedState {
+ var useAnimation: Boolean = true
+ var allowFlingGesture: Boolean = true
+ var menuGravity: Int = Gravity.TOP
+ var topBtnCount: Int = -1
+ var bottomBtnCount: Int = -1
+
+ constructor(superState: Parcelable) : super(superState)
+
+ constructor(source: Parcel, loader: ClassLoader?) : super(source, loader) {
+ useAnimation = source.readInt() == 1
+ allowFlingGesture = source.readInt() == 1
+ menuGravity = source.readInt()
+ topBtnCount = source.readInt()
+ bottomBtnCount = source.readInt()
+ }
+
+ override fun writeToParcel(out: Parcel, flags: Int) {
+ super.writeToParcel(out, flags)
+ out.writeInt(if (useAnimation) 1 else 0)
+ out.writeInt(if (allowFlingGesture) 1 else 0)
+ out.writeInt(menuGravity)
+ out.writeInt(topBtnCount)
+ out.writeInt(bottomBtnCount)
+ }
+
+ companion object {
+ @JvmField
+ val CREATOR: Parcelable.ClassLoaderCreator =
+ object : Parcelable.ClassLoaderCreator {
+ override fun createFromParcel(source: Parcel, loader: ClassLoader): SavedState {
+ return SavedState(source, loader)
+ }
+
+ override fun createFromParcel(source: Parcel): SavedState {
+ return SavedState(source, null)
+ }
+
+ override fun newArray(size: Int): Array {
+ return newArray(size)
+ }
+ }
+ }
+ }
+}
+
+fun View.absY(): Int {
+ val location = IntArray(2)
+ this.getLocationOnScreen(location)
+ return location[1]
+}
\ No newline at end of file
diff --git a/navigationrail/sample/.gitignore b/navigationrail/sample/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/navigationrail/sample/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/navigationrail/sample/build.gradle b/navigationrail/sample/build.gradle
new file mode 100644
index 00000000..b6d7a83c
--- /dev/null
+++ b/navigationrail/sample/build.gradle
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License.
+ */
+
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+
+android {
+ compileSdkVersion rootProject.ext.compileSdkVersion
+ buildToolsVersion rootProject.ext.buildToolsVersion
+
+ defaultConfig {
+ applicationId "com.microsoft.device.dualscreen.sample.navigationrail"
+ minSdkVersion rootProject.ext.minSdkVersion
+ targetSdkVersion rootProject.ext.targetSdkVersion
+
+ versionCode 1
+ versionName "1.0"
+
+ testInstrumentationRunner rootProject.ext.config.testInstrumentationRunner
+ }
+
+ buildFeatures {
+ viewBinding true
+ }
+}
+
+dependencies {
+ implementation project(path: ':navigationrail:navigationrail')
+
+ implementation kotlinDependencies.kotlinStdlib
+ implementation androidxDependencies.coreKtx
+ implementation androidxDependencies.appCompat
+ implementation androidxDependencies.constraintLayout
+ implementation androidxDependencies.lifecycleRuntimeKtx
+
+ testImplementation testDependencies.junit
+ androidTestImplementation instrumentationTestDependencies.junit
+ androidTestImplementation instrumentationTestDependencies.espressoCore
+}
\ No newline at end of file
diff --git a/navigationrail/sample/src/main/AndroidManifest.xml b/navigationrail/sample/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..b43b3771
--- /dev/null
+++ b/navigationrail/sample/src/main/AndroidManifest.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/navigationrail/sample/src/main/java/com/microsoft/device/dualscreen/sample/navigationrail/MainActivity.kt b/navigationrail/sample/src/main/java/com/microsoft/device/dualscreen/sample/navigationrail/MainActivity.kt
new file mode 100644
index 00000000..05adf6b5
--- /dev/null
+++ b/navigationrail/sample/src/main/java/com/microsoft/device/dualscreen/sample/navigationrail/MainActivity.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License.
+ */
+
+package com.microsoft.device.dualscreen.sample.navigationrail
+
+import android.os.Bundle
+import android.view.Gravity
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.isVisible
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.window.layout.WindowInfoTracker
+import androidx.window.layout.WindowLayoutInfo
+import com.microsoft.device.dualscreen.sample.navigationrail.databinding.ActivityMainBinding
+import com.microsoft.device.dualscreen.utils.wm.isFoldingFeatureHorizontal
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.launch
+
+class MainActivity : AppCompatActivity() {
+ private lateinit var binding: ActivityMainBinding
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ binding = ActivityMainBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ binding.navRailView.menuGravity = Gravity.CENTER
+ binding.navRailView.arrangeButtons(4, 3)
+ binding.navRailView.useAnimation = true
+ binding.navRailView.allowFlingGesture = true
+
+ setListeners()
+ registerWindowInfoFlow()
+ }
+
+ private fun registerWindowInfoFlow() {
+ lifecycleScope.launch(Dispatchers.Main) {
+ lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ WindowInfoTracker.getOrCreate(this@MainActivity)
+ .windowLayoutInfo(this@MainActivity)
+ .collect { windowLayoutInfo ->
+ setButtonsVisibility(windowLayoutInfo)
+ }
+ }
+ }
+ }
+
+ private fun setButtonsVisibility(windowLayoutInfo: WindowLayoutInfo) {
+ windowLayoutInfo.isFoldingFeatureHorizontal().let { isVisible ->
+ binding.content.apply {
+ moveToStart.isVisible = isVisible
+ moveToEnd.isVisible = isVisible
+ spanButtons.isVisible = isVisible
+ }
+ }
+ }
+
+ private fun setListeners() {
+ binding.content.apply {
+ moveToStart.setOnClickListener {
+ binding.navRailView.menuGravity = Gravity.TOP
+ }
+ moveToEnd.setOnClickListener {
+ binding.navRailView.menuGravity = Gravity.BOTTOM
+ }
+ spanButtons.setOnClickListener {
+ binding.navRailView.arrangeButtons(4, 3)
+ }
+ }
+ }
+}
diff --git a/navigationrail/sample/src/main/res/drawable/animal_icon.xml b/navigationrail/sample/src/main/res/drawable/animal_icon.xml
new file mode 100644
index 00000000..11d93de4
--- /dev/null
+++ b/navigationrail/sample/src/main/res/drawable/animal_icon.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/navigationrail/sample/src/main/res/drawable/bird_icon.xml b/navigationrail/sample/src/main/res/drawable/bird_icon.xml
new file mode 100644
index 00000000..678eb8d5
--- /dev/null
+++ b/navigationrail/sample/src/main/res/drawable/bird_icon.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/navigationrail/sample/src/main/res/drawable/lake_icon.xml b/navigationrail/sample/src/main/res/drawable/lake_icon.xml
new file mode 100644
index 00000000..f5536e07
--- /dev/null
+++ b/navigationrail/sample/src/main/res/drawable/lake_icon.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/navigationrail/sample/src/main/res/drawable/plant_icon.xml b/navigationrail/sample/src/main/res/drawable/plant_icon.xml
new file mode 100644
index 00000000..71f85bb3
--- /dev/null
+++ b/navigationrail/sample/src/main/res/drawable/plant_icon.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/navigationrail/sample/src/main/res/drawable/rock_icon.xml b/navigationrail/sample/src/main/res/drawable/rock_icon.xml
new file mode 100644
index 00000000..5e228adb
--- /dev/null
+++ b/navigationrail/sample/src/main/res/drawable/rock_icon.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/navigationrail/sample/src/main/res/layout/activity_main.xml b/navigationrail/sample/src/main/res/layout/activity_main.xml
new file mode 100644
index 00000000..19c8c063
--- /dev/null
+++ b/navigationrail/sample/src/main/res/layout/activity_main.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/navigationrail/sample/src/main/res/layout/header_view.xml b/navigationrail/sample/src/main/res/layout/header_view.xml
new file mode 100644
index 00000000..a45e08cc
--- /dev/null
+++ b/navigationrail/sample/src/main/res/layout/header_view.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/navigationrail/sample/src/main/res/layout/layout_content.xml b/navigationrail/sample/src/main/res/layout/layout_content.xml
new file mode 100644
index 00000000..1710921c
--- /dev/null
+++ b/navigationrail/sample/src/main/res/layout/layout_content.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/navigationrail/sample/src/main/res/menu/navigation_rail_menu.xml b/navigationrail/sample/src/main/res/menu/navigation_rail_menu.xml
new file mode 100644
index 00000000..1d281a6d
--- /dev/null
+++ b/navigationrail/sample/src/main/res/menu/navigation_rail_menu.xml
@@ -0,0 +1,45 @@
+
+
+
+
diff --git a/navigationrail/sample/src/main/res/mipmap-hdpi/ic_launcher.png b/navigationrail/sample/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 00000000..99c2ea73
Binary files /dev/null and b/navigationrail/sample/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/navigationrail/sample/src/main/res/mipmap-hdpi/ic_launcher_round.png b/navigationrail/sample/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 00000000..db615d65
Binary files /dev/null and b/navigationrail/sample/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/navigationrail/sample/src/main/res/mipmap-mdpi/ic_launcher.png b/navigationrail/sample/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 00000000..d34a1472
Binary files /dev/null and b/navigationrail/sample/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/navigationrail/sample/src/main/res/mipmap-mdpi/ic_launcher_round.png b/navigationrail/sample/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 00000000..64d0d978
Binary files /dev/null and b/navigationrail/sample/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/navigationrail/sample/src/main/res/mipmap-xhdpi/ic_launcher.png b/navigationrail/sample/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 00000000..3037841c
Binary files /dev/null and b/navigationrail/sample/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/navigationrail/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/navigationrail/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..1c34b756
Binary files /dev/null and b/navigationrail/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/navigationrail/sample/src/main/res/mipmap-xxhdpi/ic_launcher.png b/navigationrail/sample/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 00000000..45ac1683
Binary files /dev/null and b/navigationrail/sample/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/navigationrail/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/navigationrail/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..622b1366
Binary files /dev/null and b/navigationrail/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/navigationrail/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/navigationrail/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 00000000..f7d17131
Binary files /dev/null and b/navigationrail/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/navigationrail/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/navigationrail/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..404abb31
Binary files /dev/null and b/navigationrail/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/navigationrail/sample/src/main/res/values-night/themes.xml b/navigationrail/sample/src/main/res/values-night/themes.xml
new file mode 100644
index 00000000..f0ce05f6
--- /dev/null
+++ b/navigationrail/sample/src/main/res/values-night/themes.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/navigationrail/sample/src/main/res/values/colors.xml b/navigationrail/sample/src/main/res/values/colors.xml
new file mode 100644
index 00000000..e0c01dfa
--- /dev/null
+++ b/navigationrail/sample/src/main/res/values/colors.xml
@@ -0,0 +1,17 @@
+
+
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/navigationrail/sample/src/main/res/values/dimens.xml b/navigationrail/sample/src/main/res/values/dimens.xml
new file mode 100644
index 00000000..8e7c756f
--- /dev/null
+++ b/navigationrail/sample/src/main/res/values/dimens.xml
@@ -0,0 +1,14 @@
+
+
+
+
+ 16dp
+
+ 25sp
+ 10dp
+
\ No newline at end of file
diff --git a/navigationrail/sample/src/main/res/values/strings.xml b/navigationrail/sample/src/main/res/values/strings.xml
new file mode 100644
index 00000000..797a1929
--- /dev/null
+++ b/navigationrail/sample/src/main/res/values/strings.xml
@@ -0,0 +1,24 @@
+
+
+
+
+ NavigationRailView Sample
+
+ Move to top
+ Span 4-3
+ Move to bottom
+
+ Plants
+ Birds
+ Animals
+ Lakes
+ Rocks
+ Trees
+ Mountains
+
+
\ No newline at end of file
diff --git a/navigationrail/sample/src/main/res/values/themes.xml b/navigationrail/sample/src/main/res/values/themes.xml
new file mode 100644
index 00000000..968311d7
--- /dev/null
+++ b/navigationrail/sample/src/main/res/values/themes.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/settings.gradle b/settings.gradle
index 101be80e..ee47bfe6 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -39,6 +39,9 @@ include ':utils:sample'
include ':snackbar:snackbar'
include ':snackbar:sample'
+include ':navigationrail:navigationrail'
+include ':navigationrail:sample'
+
include ':navigation:navigation-common'
include ':navigation:navigation-common-ktx'
include ':navigation:navigation-runtime'
diff --git a/utils/wm-utils/src/main/java/com/microsoft/device/dualscreen/utils/wm/OnVerticalSwipeListener.kt b/utils/wm-utils/src/main/java/com/microsoft/device/dualscreen/utils/wm/OnVerticalSwipeListener.kt
new file mode 100644
index 00000000..d1eb9288
--- /dev/null
+++ b/utils/wm-utils/src/main/java/com/microsoft/device/dualscreen/utils/wm/OnVerticalSwipeListener.kt
@@ -0,0 +1,98 @@
+/*
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License.
+ */
+
+package com.microsoft.device.dualscreen.utils.wm
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.view.GestureDetector
+import android.view.GestureDetector.SimpleOnGestureListener
+import android.view.MotionEvent
+import android.view.View
+import android.view.View.OnTouchListener
+import kotlin.math.abs
+
+/**
+ * Helper class that detects a swipe left or right on the custom views
+ */
+open class OnVerticalSwipeListener(context: Context?) : OnTouchListener {
+ companion object {
+ private const val SWIPE_MIN_DISTANCE = 130
+
+ private const val SWIPE_THRESHOLD = 100
+ private const val SWIPE_VELOCITY_THRESHOLD = 100
+ }
+
+ private var gestureDetector: GestureDetector
+
+ init {
+ gestureDetector = GestureDetector(context, GestureListener())
+ }
+
+ private var flingDone = false
+ private var touchY = 0f
+
+ open fun onSwipeBottom() {}
+
+ open fun onSwipeTop() {}
+
+ open fun onTouchEvent(ev: MotionEvent?): Boolean {
+ return gestureDetector.onTouchEvent(ev)
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ override fun onTouch(v: View?, motionEvent: MotionEvent?): Boolean {
+ return gestureDetector.onTouchEvent(motionEvent)
+ }
+
+ fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
+ if (ev.actionMasked == MotionEvent.ACTION_UP || ev.actionMasked == MotionEvent.ACTION_CANCEL) {
+ flingDone = false
+ } else if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
+ touchY = ev.y
+ } else {
+ if (flingDone) {
+ return true
+ }
+ val dX: Float = abs(ev.y - touchY)
+ if (dX > SWIPE_MIN_DISTANCE) {
+ flingDone = true
+ return true
+ }
+ }
+ return false
+ }
+
+ inner class GestureListener : SimpleOnGestureListener() {
+ override fun onDown(e: MotionEvent): Boolean {
+ return false
+ }
+
+ override fun onFling(
+ e1: MotionEvent,
+ e2: MotionEvent,
+ velocityX: Float,
+ velocityY: Float
+ ): Boolean {
+ try {
+ val diffY = e2.y - e1.y
+ val diffX = e2.x - e1.x
+ if (abs(diffY) > abs(diffX) &&
+ abs(diffY) > SWIPE_THRESHOLD &&
+ abs(velocityY) > SWIPE_VELOCITY_THRESHOLD
+ ) {
+ if (diffY > 0) {
+ onSwipeBottom()
+ } else {
+ onSwipeTop()
+ }
+ }
+ } catch (exception: Exception) {
+ exception.printStackTrace()
+ }
+ return false
+ }
+ }
+}
\ No newline at end of file
diff --git a/utils/wm-utils/src/main/java/com/microsoft/device/dualscreen/utils/wm/WindowLayoutInfoExtensions.kt b/utils/wm-utils/src/main/java/com/microsoft/device/dualscreen/utils/wm/WindowLayoutInfoExtensions.kt
index 06524f8e..7f3cf214 100644
--- a/utils/wm-utils/src/main/java/com/microsoft/device/dualscreen/utils/wm/WindowLayoutInfoExtensions.kt
+++ b/utils/wm-utils/src/main/java/com/microsoft/device/dualscreen/utils/wm/WindowLayoutInfoExtensions.kt
@@ -24,6 +24,23 @@ fun WindowLayoutInfo?.isFoldingFeatureVertical(): Boolean =
this != null &&
(displayFeatures.firstOrNull() as? FoldingFeature)?.orientation == FoldingFeature.Orientation.VERTICAL
+/**
+ * Checks whether the orientation of the folding feature is horizontal
+ * @return true if the folding feature exists and is horizontal oriented, false otherwise
+ */
+fun WindowLayoutInfo?.isFoldingFeatureHorizontal(): Boolean =
+ this != null &&
+ (displayFeatures.firstOrNull() as? FoldingFeature)?.orientation == FoldingFeature.Orientation.HORIZONTAL
+
+/**
+ * Checks whether the folding feature is causing the window to be split into multiple physical areas.
+ * If tru the UI may be split to avoid overlapping the folding feature.
+ * @return true if the folding feature isSeparating
+ */
+fun WindowLayoutInfo?.isSeparating(): Boolean =
+ this != null &&
+ (displayFeatures.firstOrNull() as? FoldingFeature)?.isSeparating == true
+
/**
* Returns the first [FoldingFeature] from the [WindowLayoutInfo] or null if no [FoldingFeature] exists.
* @return The first [FoldingFeature] if it exists