diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index de15bb31c4..fe90bb19a6 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -172,6 +172,7 @@ abstract class BaseEditorActivity : private val fileManagerViewModel by viewModels() private var feedbackButtonManager: FeedbackButtonManager? = null + private var immersiveController: LandscapeImmersiveController? = null var isDestroying = false protected set @@ -448,6 +449,9 @@ abstract class BaseEditorActivity : editorBottomSheet = null gestureDetector = null + immersiveController?.destroy() + immersiveController = null + _binding = null if (isDestroying) { @@ -480,11 +484,50 @@ abstract class BaseEditorActivity : val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()) val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - _binding?.content?.editorAppBarLayout?.updatePadding(top = systemBars.top) + applyStandardInsets(systemBars, insets) + + applyImmersiveModeInsets(systemBars) + + handleKeyboardInsets(imeInsets) + } + + private fun applyStandardInsets(systemBars: Insets, windowInsets: WindowInsetsCompat) { + val content = _binding?.content ?: return + val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + + if (isLandscape) { + content.editorAppBarLayout.updatePadding(top = 0) + content.editorAppbarContent.updatePadding(top = systemBars.top) + } else { + content.editorAppBarLayout.updatePadding(top = systemBars.top) + content.editorAppbarContent.updatePadding(top = 0) + } + + immersiveController?.onSystemBarInsetsChanged(systemBars.top) applySidebarInsets(systemBars) - - _binding?.root?.applyBottomWindowInsetsPadding(insets) + _binding?.root?.applyBottomWindowInsetsPadding(windowInsets) + } + + private fun applyImmersiveModeInsets(systemBars: Insets) { + val content = _binding?.content ?: return + val baseMargin = SizeUtils.dp2px(16f) + val isRtl = content.root.layoutDirection == View.LAYOUT_DIRECTION_RTL + val endInset = if (isRtl) systemBars.left else systemBars.right + content.btnToggleTopBar.updateLayoutParams { + topMargin = baseMargin + systemBars.top + marginEnd = baseMargin + endInset + } + + content.btnToggleBottomBar.updateLayoutParams { + bottomMargin = baseMargin + systemBars.bottom + marginEnd = baseMargin + endInset + } + + content.bottomSheet.updatePadding(top = systemBars.top) + } + + private fun handleKeyboardInsets(imeInsets: Insets) { val isImeVisible = imeInsets.bottom > 0 _binding?.content?.bottomSheet?.setImeVisible(isImeVisible) @@ -612,6 +655,15 @@ abstract class BaseEditorActivity : setupStateObservers() setupViews() + immersiveController = LandscapeImmersiveController( + contentBinding = content, + bottomSheetBehavior = editorBottomSheet!!, + coroutineScope = lifecycleScope, + ).also { + it.bind() + it.onConfigurationChanged(resources.configuration) + } + setupContainers() setupDiagnosticInfo() @@ -643,6 +695,7 @@ abstract class BaseEditorActivity : override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) + immersiveController?.onConfigurationChanged(newConfig) } private fun setupToolbar() { @@ -700,9 +753,10 @@ abstract class BaseEditorActivity : _binding?.apply { contentCard.progress = progress val insetsTop = systemBarInsets?.top ?: 0 - content.editorAppBarLayout.updatePadding( - top = (insetsTop * (1f - progress)).roundToInt(), - ) + val topInset = (insetsTop * (1f - progress)).roundToInt() + + content.editorAppbarContent.updatePadding(top = topInset) + memUsageView.chart.updateLayoutParams { topMargin = (insetsTop * progress).roundToInt() } @@ -792,6 +846,7 @@ abstract class BaseEditorActivity : } override fun onPause() { + immersiveController?.onPause() super.onPause() memoryUsageWatcher.listener = null memoryUsageWatcher.stopWatching(false) @@ -801,6 +856,7 @@ abstract class BaseEditorActivity : } override fun onResume() { + immersiveController?.onResume() super.onResume() invalidateOptionsMenu() @@ -1299,7 +1355,8 @@ abstract class BaseEditorActivity : slideOffset: Float, ) { content.apply { - val editorScale = 1 - slideOffset * (1 - EDITOR_CONTAINER_SCALE_FACTOR) + val safeOffset = slideOffset.coerceAtLeast(0f) + val editorScale = 1 - safeOffset * (1 - EDITOR_CONTAINER_SCALE_FACTOR) this.bottomSheet.onSlide(slideOffset) this.viewContainer.scaleX = editorScale this.viewContainer.scaleY = editorScale diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/LandscapeImmersiveController.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/LandscapeImmersiveController.kt new file mode 100644 index 0000000000..201c88b5ee --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/LandscapeImmersiveController.kt @@ -0,0 +1,406 @@ +package com.itsaky.androidide.activities.editor + +import android.annotation.SuppressLint +import android.content.res.Configuration +import android.view.MotionEvent +import android.view.View +import android.view.View.OnLayoutChangeListener +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import androidx.core.view.updateLayoutParams +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.appbar.AppBarLayout +import com.itsaky.androidide.databinding.ContentEditorBinding +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +/** + * Controls immersive behavior for the editor in landscape mode. + * + * Top bar: + * - expands/collapses through AppBarLayout behavior + * - supports auto-hide after being shown + * - pauses auto-hide while the user is interacting with the top bar + * + * Bottom bar: + * - remains backed by BottomSheetBehavior + * - can be visually hidden by translating the collapsed peek area + */ +class LandscapeImmersiveController( + contentBinding: ContentEditorBinding, + private val bottomSheetBehavior: BottomSheetBehavior, + private val coroutineScope: CoroutineScope, +) { + private val topBar = contentBinding.editorAppBarLayout + private val appBarContent = contentBinding.editorAppbarContent + private val viewContainer = contentBinding.viewContainer + private val editorContainer = contentBinding.editorContainer + private val bottomSheet = contentBinding.bottomSheet + private val topToggle = contentBinding.btnToggleTopBar + private val bottomToggle = contentBinding.btnToggleBottomBar + + private var autoHideJob: Job? = null + private var isBound = false + + private var isLandscape = false + private var isTopBarRequestedVisible = true + private var isBottomBarRequestedVisible = true + private var isBottomBarShown = true + private var isPendingBottomBarHideAfterCollapse = false + private var isUserInteractingWithTopBar = false + + private var statusBarTopInset = 0 + private var currentAppBarOffset = 0 + private var lastKnownScrollRange = 0 + private val defaultEditorBottomMargin = + (editorContainer.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin + + private val topBarOffsetListener = + AppBarLayout.OnOffsetChangedListener { appBarLayout, verticalOffset -> + currentAppBarOffset = verticalOffset + lastKnownScrollRange = appBarLayout.totalScrollRange + updateEditorTopInset() + } + + private val appBarLayoutChangeListener = + OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + if (!isLandscape) return@OnLayoutChangeListener + + val newScrollRange = topBar.totalScrollRange + val scrollRangeChanged = newScrollRange != lastKnownScrollRange + + lastKnownScrollRange = newScrollRange + + val isVisuallyOutOfSync = !isTopBarRequestedVisible && currentAppBarOffset > -newScrollRange + + if (scrollRangeChanged || isVisuallyOutOfSync) { + topBar.post(::enforceCollapsedStateIfNeeded) + } + } + + private fun enforceCollapsedStateIfNeeded() { + if (isTopBarRequestedVisible) return + + collapseTopBarWithoutAnimation() + updateEditorTopInset() + } + + private val bottomSheetCallback = + object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged(bottomSheetView: View, newState: Int) { + when (newState) { + BottomSheetBehavior.STATE_EXPANDED, + BottomSheetBehavior.STATE_HALF_EXPANDED -> onBottomBarExpandedOrHalfExpanded() + + BottomSheetBehavior.STATE_COLLAPSED -> onBottomBarCollapsed() + + BottomSheetBehavior.STATE_DRAGGING, + BottomSheetBehavior.STATE_SETTLING -> Unit + + BottomSheetBehavior.STATE_HIDDEN -> { + isBottomBarShown = false + } + } + } + + override fun onSlide(bottomSheetView: View, slideOffset: Float) = Unit + } + + init { + setupClickListeners() + } + + private val topBarTouchObserver: (MotionEvent) -> Unit = { event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> onTopBarInteractionStarted() + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> onTopBarInteractionEnded() + } + } + + @SuppressLint("ClickableViewAccessibility") + fun bind() { + if (isBound) return + isBound = true + + topBar.addOnOffsetChangedListener(topBarOffsetListener) + appBarContent.addOnLayoutChangeListener(appBarLayoutChangeListener) + bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback) + appBarContent.onTouchEventObserved = topBarTouchObserver + } + + fun onPause() { + cancelAutoHide() + isUserInteractingWithTopBar = false + cancelBottomSheetAnimation() + setBottomSheetTranslation( + if (!isBottomBarShown && bottomSheetBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { + bottomSheetBehavior.peekHeight.toFloat() + } else { + 0f + }, + ) + } + + fun onResume() { + if (isLandscape && isTopBarRequestedVisible && !isUserInteractingWithTopBar) { + scheduleTopBarAutoHide() + } + } + + @SuppressLint("ClickableViewAccessibility") + fun destroy() { + onPause() + + if (!isBound) return + isBound = false + + topToggle.setOnClickListener(null) + bottomToggle.setOnClickListener(null) + + topBar.removeOnOffsetChangedListener(topBarOffsetListener) + appBarContent.removeOnLayoutChangeListener(appBarLayoutChangeListener) + bottomSheetBehavior.removeBottomSheetCallback(bottomSheetCallback) + appBarContent.onTouchEventObserved = null + } + + fun onConfigurationChanged(newConfig: Configuration) { + isLandscape = newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE + + appBarContent.updateLayoutParams { + scrollFlags = if (isLandscape) { + AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL or + AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS or + AppBarLayout.LayoutParams.SCROLL_FLAG_SNAP + } else { + 0 + } + } + + if (isLandscape) { + enableImmersiveMode() + } else { + disableImmersiveMode() + } + + updateEditorBottomInset() + } + + fun onSystemBarInsetsChanged(topInset: Int) { + statusBarTopInset = topInset + updateEditorTopInset() + } + + private fun setupClickListeners() { + topToggle.setOnClickListener { + if (!isLandscape) return@setOnClickListener + if (isTopBarRequestedVisible) hideTopBar() else showTopBar(autoHide = true) + } + + bottomToggle.setOnClickListener { + if (!isLandscape) return@setOnClickListener + if (isBottomBarShown) hideBottomBar() else showBottomBar(expandHalfWay = true) + } + } + + private fun onTopBarInteractionStarted() { + if (!isLandscape || !isTopBarRequestedVisible) return + isUserInteractingWithTopBar = true + cancelAutoHide() + } + + private fun onTopBarInteractionEnded() { + if (!isLandscape || !isTopBarRequestedVisible) return + isUserInteractingWithTopBar = false + scheduleTopBarAutoHide() + } + + private fun onBottomBarExpandedOrHalfExpanded() { + cancelBottomSheetAnimation() + setBottomSheetTranslation(0f) + isBottomBarShown = true + isBottomBarRequestedVisible = true + isPendingBottomBarHideAfterCollapse = false + updateEditorBottomInset() + } + + private fun onBottomBarCollapsed() { + if (isPendingBottomBarHideAfterCollapse && !isBottomBarRequestedVisible) { + isPendingBottomBarHideAfterCollapse = false + applyHiddenBottomBarTranslation(animate = true) + return + } + + cancelBottomSheetAnimation() + setBottomSheetTranslation(0f) + isBottomBarShown = true + updateEditorBottomInset() + } + + private fun enableImmersiveMode() { + setTogglesVisible(true) + + topBar.post { hideTopBar(animate = false) } + bottomSheet.post { hideBottomBar(animate = false) } + } + + private fun disableImmersiveMode() { + cancelAutoHide() + isUserInteractingWithTopBar = false + setTogglesVisible(false) + + isTopBarRequestedVisible = true + topBar.setExpanded(true, false) + + isBottomBarRequestedVisible = true + isBottomBarShown = true + isPendingBottomBarHideAfterCollapse = false + + cancelBottomSheetAnimation() + setBottomSheetTranslation(0f) + + updateEditorBottomInset() + } + + private fun setTogglesVisible(visible: Boolean) { + topToggle.isVisible = visible + bottomToggle.isVisible = visible + } + + private fun showTopBar(autoHide: Boolean, animate: Boolean = true) { + cancelAutoHide() + isTopBarRequestedVisible = true + topBar.setExpanded(true, animate) + if (autoHide) scheduleTopBarAutoHide() + } + + private fun hideTopBar(animate: Boolean = true) { + cancelAutoHide() + isUserInteractingWithTopBar = false + isTopBarRequestedVisible = false + topBar.setExpanded(false, animate) + } + + private fun collapseTopBarWithoutAnimation() { + topBar.setExpanded(false, false) + currentAppBarOffset = -topBar.totalScrollRange + } + + private fun scheduleTopBarAutoHide() { + if (isUserInteractingWithTopBar || !isTopBarRequestedVisible) return + + autoHideJob = coroutineScope.launch { + delay(TOP_BAR_AUTO_HIDE_DELAY_MS) + if (!isUserInteractingWithTopBar && isTopBarRequestedVisible) { + hideTopBar() + } + } + } + + private fun cancelAutoHide() { + autoHideJob?.cancel() + autoHideJob = null + } + + private fun showBottomBar(animate: Boolean = true, expandHalfWay: Boolean = false) { + isBottomBarRequestedVisible = true + isBottomBarShown = true + isPendingBottomBarHideAfterCollapse = false + + updateEditorBottomInset() + + if (expandHalfWay) { + bottomSheetBehavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED + } else { ensureBottomSheetCollapsed() } + + animateBottomSheetTranslation(to = 0f, animate = animate) + } + + private fun hideBottomBar(animate: Boolean = true) { + isBottomBarRequestedVisible = false + isBottomBarShown = false + updateEditorBottomInset() + + if (bottomSheetBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { + applyHiddenBottomBarTranslation(animate) + return + } + + isPendingBottomBarHideAfterCollapse = true + ensureBottomSheetCollapsed() + } + + private fun ensureBottomSheetCollapsed() { + if (bottomSheetBehavior.state != BottomSheetBehavior.STATE_COLLAPSED) { + bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + } + } + + private fun applyHiddenBottomBarTranslation(animate: Boolean) { + animateBottomSheetTranslation( + to = bottomSheetBehavior.peekHeight.toFloat(), + animate = animate, + ) + } + + private fun animateBottomSheetTranslation(to: Float, animate: Boolean) { + cancelBottomSheetAnimation() + if (animate) { + bottomSheet.animate() + .translationY(to) + .setDuration(BOTTOM_BAR_ANIMATION_DURATION_MS) + .start() + } else { + setBottomSheetTranslation(to) + } + } + + private fun cancelBottomSheetAnimation() { + bottomSheet.animate().cancel() + } + + private fun setBottomSheetTranslation(value: Float) { + bottomSheet.translationY = value + } + + private fun updateEditorBottomInset() { + val bottomMargin = if (isLandscape) { + if (isBottomBarShown) bottomSheetBehavior.peekHeight else 0 + } else { + defaultEditorBottomMargin + } + + editorContainer.updateLayoutParams { + if (bottomMargin != this.bottomMargin) { + this.bottomMargin = bottomMargin + } + } + } + + /** + * Applies the editor top inset progressively as the app bar collapses, + * avoiding a jump at the end of the animation. + */ + private fun updateEditorTopInset() { + val topPadding = when { + !isLandscape || lastKnownScrollRange <= 0 -> 0 + else -> { + val collapseFraction = + (-currentAppBarOffset.toFloat() / lastKnownScrollRange.toFloat()) + .coerceIn(0f, 1f) + (statusBarTopInset * collapseFraction).roundToInt() + } + } + + viewContainer.updatePadding(top = topPadding) + } + + private companion object { + const val TOP_BAR_AUTO_HIDE_DELAY_MS = 3500L + const val BOTTOM_BAR_ANIMATION_DURATION_MS = 200L + } +} diff --git a/app/src/main/res/drawable/bg_hollow_green_circle.xml b/app/src/main/res/drawable/bg_hollow_green_circle.xml new file mode 100644 index 0000000000..4afdaace9e --- /dev/null +++ b/app/src/main/res/drawable/bg_hollow_green_circle.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/layout-land/content_editor.xml b/app/src/main/res/layout-land/content_editor.xml index 20a0336483..f30bc81e71 100644 --- a/app/src/main/res/layout-land/content_editor.xml +++ b/app/src/main/res/layout-land/content_editor.xml @@ -20,8 +20,7 @@ xmlns:tools="http://schemas.android.com/tools" android:id="@+id/realContainer" android:layout_width="match_parent" - android:layout_height="match_parent" - android:animateLayoutChanges="true"> + android:layout_height="match_parent"> - - + android:orientation="vertical" + app:layout_scrollFlags="scroll|enterAlways|snap"> - + - - + + + + + + + + + android:paddingBottom="0dp" /> - + - - - + android:indeterminate="true" /> - - - + @@ -158,4 +165,34 @@ android:id="@+id/diagnosticInfo" layout="@layout/layout_diagnostic_info" /> + + + + diff --git a/app/src/main/res/layout/content_editor.xml b/app/src/main/res/layout/content_editor.xml index ff4415253f..d1eb312308 100644 --- a/app/src/main/res/layout/content_editor.xml +++ b/app/src/main/res/layout/content_editor.xml @@ -31,63 +31,70 @@ android:fitsSystemWindows="false" app:layout_behavior="com.google.android.material.appbar.AppBarLayout$Behavior"> - - + + - - + + + + + + + + android:paddingBottom="0dp" /> + - - - - - - + android:indeterminate="true" /> - + + + + + + \ No newline at end of file diff --git a/common/src/main/java/com/itsaky/androidide/ui/TouchObservingLinearLayout.kt b/common/src/main/java/com/itsaky/androidide/ui/TouchObservingLinearLayout.kt new file mode 100644 index 0000000000..f9b6376f71 --- /dev/null +++ b/common/src/main/java/com/itsaky/androidide/ui/TouchObservingLinearLayout.kt @@ -0,0 +1,19 @@ +package com.itsaky.androidide.ui + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import android.widget.LinearLayout + +class TouchObservingLinearLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, +) : LinearLayout(context, attrs) { + + var onTouchEventObserved: ((MotionEvent) -> Unit)? = null + + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + onTouchEventObserved?.invoke(ev) + return super.dispatchTouchEvent(ev) + } +} diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 11f5ec5cab..29a5be4cf3 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -835,6 +835,8 @@ Breakpoint Project name Project Options + Toggle top bar + Toggle bottom bar Plugin Manager