Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 35 additions & 8 deletions packages/vuetify/src/components/VAppBar/VAppBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,22 @@ export const VAppBar = genericComponent<VToolbarSlots>()({
!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 ||
Expand Down Expand Up @@ -120,15 +130,32 @@ export const VAppBar = genericComponent<VToolbarSlots>()({

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)
})
})

Expand Down
80 changes: 78 additions & 2 deletions packages/vuetify/src/composables/scroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,14 @@ export const makeScrollProps = propsFactory({

export interface ScrollArguments {
canScroll?: Readonly<Ref<boolean>>
layoutSize?: Readonly<Ref<number>>
}

export function useScroll (
props: ScrollProps,
args: ScrollArguments = {},
) {
const { canScroll } = args
const { canScroll, layoutSize } = args
let previousScroll = 0
let previousScrollHeight = 0
const target = ref<Element | Window | null>(null)
Expand All @@ -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)
Expand All @@ -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

Expand All @@ -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, () => {
Expand All @@ -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
Expand All @@ -127,5 +200,8 @@ export function useScroll (
// later (2 chars chlng)
isScrollingUp,
savedScroll,
isAtBottom,
reachedBottomWhileScrollingDown,
hasEnoughScrollableSpace,
}
}