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 @@ + + + + + + + + + +