diff --git a/packages/vuetify/src/components/VAppBar/VAppBar.tsx b/packages/vuetify/src/components/VAppBar/VAppBar.tsx index c22a75363bc..7f415a20ffa 100644 --- a/packages/vuetify/src/components/VAppBar/VAppBar.tsx +++ b/packages/vuetify/src/components/VAppBar/VAppBar.tsx @@ -78,12 +78,22 @@ export const VAppBar = genericComponent()({ !isActive.value ) }) + + const appBarHeight = computed(() => { + const height = vToolbarRef.value?.contentHeight ?? 0 + const extensionHeight = vToolbarRef.value?.extensionHeight ?? 0 + return height + extensionHeight + }) + const { currentScroll, scrollThreshold, isScrollingUp, scrollRatio, - } = useScroll(props, { canScroll }) + isAtBottom, + reachedBottomWhileScrollingDown, + hasEnoughScrollableSpace, + } = useScroll(props, { canScroll, layoutSize: appBarHeight }) const canHide = toRef(() => ( scrollBehavior.value.hide || @@ -120,15 +130,32 @@ export const VAppBar = genericComponent()({ useToggleScope(() => !!props.scrollBehavior, () => { watchEffect(() => { - if (canHide.value) { - if (scrollBehavior.value.inverted) { - isActive.value = currentScroll.value > scrollThreshold.value - } else { - isActive.value = isScrollingUp.value || (currentScroll.value < scrollThreshold.value) - } - } else { + if (!canHide.value) { isActive.value = true + return + } + + if (scrollBehavior.value.inverted) { + isActive.value = currentScroll.value > scrollThreshold.value + return } + + // If there's not enough scrollable space, don't apply scroll-hide behavior at all + // This prevents flickering/bouncing animations on short pages + if (!hasEnoughScrollableSpace.value) { + isActive.value = true + return + } + + // Prevent navbar from showing when we reached bottom while scrolling down + // This handles the case where scroll momentum causes to hit bottom during hide transition + if (reachedBottomWhileScrollingDown.value) { + isActive.value = false + return + } + + // Normal behavior: show when scrolling up (and not at bottom) or above threshold + isActive.value = (isScrollingUp.value && !isAtBottom.value) || (currentScroll.value < scrollThreshold.value) }) }) diff --git a/packages/vuetify/src/composables/scroll.ts b/packages/vuetify/src/composables/scroll.ts index 179a3cbe294..4cae14bc863 100644 --- a/packages/vuetify/src/composables/scroll.ts +++ b/packages/vuetify/src/composables/scroll.ts @@ -36,13 +36,14 @@ export const makeScrollProps = propsFactory({ export interface ScrollArguments { canScroll?: Readonly> + layoutSize?: Readonly> } export function useScroll ( props: ScrollProps, args: ScrollArguments = {}, ) { - const { canScroll } = args + const { canScroll, layoutSize } = args let previousScroll = 0 let previousScrollHeight = 0 const target = ref(null) @@ -51,6 +52,9 @@ export function useScroll ( const currentThreshold = shallowRef(0) const isScrollActive = shallowRef(false) const isScrollingUp = shallowRef(false) + const isAtBottom = shallowRef(false) + const reachedBottomWhileScrollingDown = shallowRef(false) + const hasEnoughScrollableSpace = shallowRef(true) const scrollThreshold = computed(() => { return Number(props.scrollThreshold) @@ -64,6 +68,33 @@ export function useScroll ( return clamp(((scrollThreshold.value - currentScroll.value) / scrollThreshold.value) || 0) }) + const getScrollMetrics = (targetEl: Element | Window) => { + const clientHeight = ('window' in targetEl) ? window.innerHeight : targetEl.clientHeight + const scrollHeight = ('window' in targetEl) ? document.documentElement.scrollHeight : targetEl.scrollHeight + return { clientHeight, scrollHeight } + } + + const checkScrollableSpace = () => { + const targetEl = target.value + if (!targetEl) return + + const { clientHeight, scrollHeight } = getScrollMetrics(targetEl) + const maxScrollableDistance = scrollHeight - clientHeight + + // When the scroll-hide element (like AppBar) hides, it causes the page to grow + // We need extra scrollable space beyond the threshold to prevent bouncing + // Add the element's height to the required minimum distance + const elementHeight = layoutSize?.value || 0 + const minRequiredDistance = scrollThreshold.value + elementHeight + + // Only enable scroll-hide if there's enough scrollable space + hasEnoughScrollableSpace.value = maxScrollableDistance > minRequiredDistance + } + + const onResize = () => { + checkScrollableSpace() + } + const onScroll = () => { const targetEl = target.value @@ -74,12 +105,45 @@ export function useScroll ( const currentScrollHeight = targetEl instanceof Window ? document.documentElement.scrollHeight : targetEl.scrollHeight if (previousScrollHeight !== currentScrollHeight) { + // If page is growing (content loading), recalculate scrollable space + // If page is shrinking (likely due to navbar animation), don't recalculate + if (currentScrollHeight > previousScrollHeight) { + checkScrollableSpace() + } previousScrollHeight = currentScrollHeight - return } isScrollingUp.value = currentScroll.value < previousScroll currentThreshold.value = Math.abs(currentScroll.value - scrollThreshold.value) + + // Detect if at bottom of page + const { clientHeight, scrollHeight } = getScrollMetrics(targetEl) + const atBottom = currentScroll.value + clientHeight >= scrollHeight - 5 + + // Track when bottom is reached during downward scroll + // Only set flag if ALL conditions are met: + // 1. Scrolled past threshold (navbar is hiding) + // 2. Page has enough scrollable space for scroll-hide + // This prevents activation on short pages or edge cases + if (!isScrollingUp.value && atBottom && + currentScroll.value >= scrollThreshold.value && + hasEnoughScrollableSpace.value) { + reachedBottomWhileScrollingDown.value = true + } + + // Reset the flag when: + // 1. Scrolling up away from bottom (with small tolerance for touchpad/momentum scrolling) + // 2. Scroll position jumped significantly (e.g., navigation, scroll restoration) + // 3. Scroll is at the very top (page navigation resets to top) + const scrollJumped = Math.abs(currentScroll.value - previousScroll) > 100 + const atTop = currentScroll.value <= 5 + const scrolledUpSignificantly = isScrollingUp.value && (previousScroll - currentScroll.value) > 1 + if ((scrolledUpSignificantly && !atBottom) || (scrollJumped && currentScroll.value < scrollThreshold.value) || atTop) { + reachedBottomWhileScrollingDown.value = false + } + + // Update state + isAtBottom.value = atBottom } watch(isScrollingUp, () => { @@ -104,11 +168,20 @@ export function useScroll ( target.value?.removeEventListener('scroll', onScroll) target.value = newTarget target.value.addEventListener('scroll', onScroll, { passive: true }) + + // Check scrollable space when target is set + Promise.resolve().then(() => { + checkScrollableSpace() + }) }, { immediate: true }) + + // Listen to window resize to recalculate scrollable space + window.addEventListener('resize', onResize, { passive: true }) }) onBeforeUnmount(() => { target.value?.removeEventListener('scroll', onScroll) + window.removeEventListener('resize', onResize) }) // Do we need this? If yes - seems that @@ -127,5 +200,8 @@ export function useScroll ( // later (2 chars chlng) isScrollingUp, savedScroll, + isAtBottom, + reachedBottomWhileScrollingDown, + hasEnoughScrollableSpace, } }