support iOS 26 navbar

This commit is contained in:
Timi
2026-08-29 23:40:26 +08:00
parent aac6989728
commit d0914fa683
4 changed files with 516 additions and 173 deletions
+152
View File
@@ -0,0 +1,152 @@
<template>
<t-navbar
ref="navBarRef"
class="nav-bar"
:left-arrow="!!navBarStore.canBack"
:z-index="20"
@left-click="emit('back')"
>
<template #title>
<div id="app-nav-title" class="slot">
<span v-if="!navBarStore.customTitleVisible" class="nav-title-text" v-text="navBarStore.title" />
</div>
</template>
<template v-if="navBarStore.customRightVisible || navBarStore.rightIcon || navBarStore.rightText" #right>
<div class="nav-right-slot">
<div v-if="navBarStore.customRightVisible" id="app-nav-right" />
<t-badge
v-else-if="navBarStore.rightIcon && navBarStore.rightBadge !== undefined"
class="badge"
:count="navBarStore.rightBadge"
:max-count="99"
:show-zero="false"
>
<button
class="button"
type="button"
:disabled="navBarStore.rightDisabled"
@click="emit('rightAction')"
>
<t-icon :name="navBarStore.rightIcon" size="1.25rem" />
</button>
</t-badge>
<button
v-else-if="navBarStore.rightIcon"
class="button"
type="button"
:disabled="navBarStore.rightDisabled"
@click="emit('rightAction')"
>
<t-icon :name="navBarStore.rightIcon" size="1.25rem" />
</button>
<span
v-else
v-text="navBarStore.rightText"
class="nav-text"
:style="{ color: navBarStore.rightDisabled ? 'var(--td-text-color-disabled)' : navBarStore.rightTextColor ? navBarStore.rightTextColor : 'var(--td-text-color-primary, #333)' }"
:class="{ clickable: !!navBarStore.rightAction && !navBarStore.rightDisabled, disabled: navBarStore.rightDisabled }"
@click="emit('rightAction')"
></span>
</div>
</template>
</t-navbar>
</template>
<script setup lang="ts">
import { useNavBarStore } from "@/store/navBarStore";
type TNavBarRef = { $el?: HTMLElement };
const emit = defineEmits<{
back: [];
rightAction: [];
}>();
const navBarStore = useNavBarStore();
const navBarRef = ref<TNavBarRef>();
function getElement(): HTMLElement | undefined {
return navBarRef.value?.$el?.querySelector(".t-navbar__content") || undefined;
}
defineExpose({
getElement
});
</script>
<style scoped lang="less">
.nav-bar {
:deep(.t-navbar__content) {
color: var(--tui-black, #000);
overflow: visible;
box-shadow: 0 3px 8px rgba(0, 0, 0, .1);
background: rgba(250, 250, 250, .8);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.badge {
:deep(.t-badge__content) {
display: flex;
align-items: center;
}
}
.button {
padding: 0;
border: none;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--td-text-color-primary, #333);
background: transparent;
&:disabled {
color: var(--td-text-color-disabled);
cursor: not-allowed;
}
}
.slot {
min-width: 0;
}
.nav-title-text {
overflow: hidden;
display: block;
white-space: nowrap;
text-overflow: ellipsis;
}
.nav-right-slot {
width: 6rem;
height: 100%;
display: flex;
align-items: center;
justify-content: flex-end;
:deep(.t-badge) {
display: inline-flex;
}
}
.nav-text {
cursor: default;
&.clickable {
cursor: pointer;
}
&.disabled {
cursor: not-allowed;
}
}
}
:deep(.t-navbar__right),
:deep(.t-navbar__right-content) {
overflow: visible;
}
</style>
+209
View File
@@ -0,0 +1,209 @@
<template>
<div ref="navBarRef" class="ios26-nav-bar">
<div class="left">
<button
v-if="navBarStore.canBack"
class="pill back"
type="button"
@click="emit('back')"
>
<t-icon name="chevron-left" size="1.35rem" />
</button>
</div>
<div id="title" class="pill title">
<span
v-if="!navBarStore.customTitleVisible"
class="text"
v-text="navBarStore.title"
/>
</div>
<div class="right">
<div
v-if="navBarStore.customRightVisible || navBarStore.rightIcon || navBarStore.rightText"
class="pill slot"
>
<div v-if="navBarStore.customRightVisible" id="app-nav-right" />
<t-badge
v-else-if="navBarStore.rightIcon && navBarStore.rightBadge !== undefined"
class="badge"
:count="navBarStore.rightBadge"
:max-count="99"
:show-zero="false"
>
<button
class="button"
type="button"
:disabled="navBarStore.rightDisabled"
@click="emit('rightAction')"
>
<t-icon :name="navBarStore.rightIcon" size="1.25rem" />
</button>
</t-badge>
<button
v-else-if="navBarStore.rightIcon"
class="button"
type="button"
:disabled="navBarStore.rightDisabled"
@click="emit('rightAction')"
>
<t-icon :name="navBarStore.rightIcon" size="1.25rem" />
</button>
<span
v-else
v-text="navBarStore.rightText"
class="text"
:style="{ color: navBarStore.rightDisabled ? 'var(--td-text-color-disabled)' : navBarStore.rightTextColor ? navBarStore.rightTextColor : 'var(--td-text-color-primary, #333)' }"
:class="{ clickable: !!navBarStore.rightAction && !navBarStore.rightDisabled, disabled: navBarStore.rightDisabled }"
@click="emit('rightAction')"
></span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useNavBarStore } from "@/store/navBarStore";
const emit = defineEmits<{
back: [];
rightAction: [];
}>();
const navBarStore = useNavBarStore();
const navBarRef = ref<HTMLElement>();
function getElement(): HTMLElement | undefined {
return navBarRef.value;
}
defineExpose({
getElement
});
</script>
<style scoped lang="less">
.ios26-nav-bar {
top: calc(env(safe-area-inset-top, 0px) + 2rem);
left: 0;
right: 0;
z-index: 20;
height: 2.5rem;
padding: 0 .75rem;
position: fixed;
.pill {
height: 2.5rem;
display: flex;
min-width: 0;
align-items: center;
border-radius: 999px;
background: rgba(250, 250, 250, .72);
box-shadow: 0 .25rem .875rem rgba(0, 0, 0, .1);
backdrop-filter: blur(18px) saturate(180%);
-webkit-backdrop-filter: blur(18px) saturate(180%);
}
.left,
.right,
#title {
top: 0;
position: absolute;
}
.left {
left: .75rem;
.back {
width: 2.5rem;
justify-content: center;
}
}
.title {
left: 50%;
padding: 0 1rem;
min-width: 8rem;
max-width: calc(100vw - 4rem);
transform: translateX(-50%);
justify-content: center;
.text {
overflow: hidden;
color: var(--td-text-color-primary, #333);
display: block;
font-size: 1rem;
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.right {
right: .75rem;
display: flex;
justify-content: flex-end;
.button {
display: inline-flex;
align-items: center;
justify-content: center;
}
.slot {
gap: .5rem;
max-width: 8rem;
padding: 0 .75rem;
justify-content: flex-end;
:deep(.t-badge) {
display: inline-flex;
}
}
.badge {
:deep(.t-badge__content) {
display: flex;
align-items: center;
}
}
.text {
overflow: hidden;
cursor: default;
white-space: nowrap;
text-overflow: ellipsis;
&.clickable {
cursor: pointer;
}
&.disabled {
cursor: not-allowed;
}
}
}
.back,
.right .button {
padding: 0;
border: none;
color: var(--td-text-color-primary, #333);
background: rgba(250, 250, 250, .72);
&:disabled {
color: var(--td-text-color-disabled);
cursor: not-allowed;
}
}
@media (prefers-color-scheme: dark) {
.pill,
.back,
.button {
background: rgba(36, 36, 36, .72);
}
}
}
</style>
+148
View File
@@ -0,0 +1,148 @@
<template>
<ios26-nav-bar
v-if="navBarStore.isShowing && isIos26Standalone"
ref="navBarRef"
@back="doBack"
@right-action="doRightAction"
/>
<default-nav-bar
v-else-if="navBarStore.isShowing"
ref="navBarRef"
@back="doBack"
@right-action="doRightAction"
/>
</template>
<script setup lang="ts">
import DefaultNavBar from "@/components/navbar/DefaultNavBar.vue";
import Ios26NavBar from "@/components/navbar/iOS26NavBar.vue";
import { useNavBarStore } from "@/store/navBarStore";
type NavBarExpose = {
getElement: () => HTMLElement | undefined;
};
const emit = defineEmits<{
heightChange: [value: string];
}>();
const router = useRouter();
const navBarStore = useNavBarStore();
// ---------- 导航高度 ----------
const navBarRef = ref<NavBarExpose>();
const isIos26Standalone = ref(false);
let navBarResizeObs: ResizeObserver | undefined;
onMounted(async () => {
isIos26Standalone.value = isIos26StandaloneMode();
await syncNavBarHeight();
});
watch(() => navBarStore.isShowing, async () => {
await syncNavBarHeight();
}, { flush: "post" });
onUnmounted(() => {
if (navBarResizeObs) {
navBarResizeObs.disconnect();
}
});
async function syncNavBarHeight(): Promise<void> {
if (navBarResizeObs) {
navBarResizeObs.disconnect();
navBarResizeObs = undefined;
}
emitLayoutHeight();
await nextTick();
const navBarElement = navBarRef.value?.getElement();
if (!navBarElement) {
navBarStore.setHeight(0);
return;
}
navBarResizeObs = new ResizeObserver((entries) => {
for (const entry of entries) {
navBarStore.setHeight(entry.contentRect.height);
emitLayoutHeight();
return;
}
});
navBarResizeObs.observe(navBarElement);
navBarStore.setHeight(navBarElement.getBoundingClientRect().height);
emitLayoutHeight();
}
function emitLayoutHeight(): void {
if (!navBarStore.isShowing) {
emit("heightChange", "0px");
return;
}
if (isIos26Standalone.value) {
emit("heightChange", "calc(env(safe-area-inset-top, 0px) + 5.25rem)");
return;
}
emit("heightChange", "48px");
}
function isIos26StandaloneMode(): boolean {
const nav = window.navigator as Navigator & { standalone?: boolean };
const isStandalone = nav.standalone === true || window.matchMedia("(display-mode: standalone)").matches;
if (!isStandalone) {
return false;
}
return 26 <= getIosEffectiveMajorVersion();
}
/**
* iOS 26 standalone 的 UA 可能仍带 iPhone OS 18_x,但 Version 主版本会暴露新 WebApp 行为。
*/
function getIosEffectiveMajorVersion(): number {
const nav = window.navigator;
const userAgent = nav.userAgent;
const isIosLike = /iP(hone|od|ad)/.test(userAgent) || (nav.platform === "MacIntel" && 1 < nav.maxTouchPoints);
if (!isIosLike) {
return 0;
}
const osMajor = getUserAgentMajorVersion(userAgent, /OS (\d+)[._]/);
const versionMajor = getUserAgentMajorVersion(userAgent, /Version\/(\d+)[._]/);
return Math.max(osMajor, versionMajor);
}
function getUserAgentMajorVersion(userAgent: string, pattern: RegExp): number {
const match = userAgent.match(pattern);
if (!match) {
return 0;
}
return Number(match[1]) || 0;
}
// ---------- 导航返回 ----------
function doBack(): void {
if (navBarStore.backTo) {
router.push(navBarStore.backTo);
navBarStore.setBackTo(undefined);
return;
}
router.back();
}
// ---------- 右侧操作 ----------
async function doRightAction(): Promise<void> {
if (navBarStore.rightDisabled || !navBarStore.rightAction) {
return;
}
await navBarStore.rightAction();
}
</script>