跳过正文

Hugo Blowfish 自定义:导航栏上磨砂岛

··2151 字·5 分钟· loading · loading ·
加绒
作者
加绒
融雪之前,牧神搭上春色的火车,而日光在我们之间。
目录
博客的自定义 - 这篇文章属于一个选集。
§ 2: 本文

TL;DR:如果不想折腾,可以在文末获取源码,直接粘贴到对应目录即可。


书接上回,这次从这个美丽的灵动岛菜单栏的自定义开始说起。

其实 Blowfish 作者自己设计的这个菜单就和自己的背景相当契合了,如图。

image.png

本来就觉得,嗯看着很顺眼挺不错的,直到我突然想起来很久之前看到过这么个

image.png

感觉这种小巧的导航菜单栏确实更适合博客。想到 Hugo 强大方便的自定义,于是就立刻开始搞了起来。所以,我的魔改版:

  1. 圆角!而且要非全宽。
  2. 滚动切换网站标题 / 页面标题
  3. 随滚动深度增加模糊效果(Blowfish 原生支持)
  4. 下划隐藏,上划显示

image.png

GIF_2024-10-9_21-33-18.gif

一个个提吧!

写在前面
#

如果用户的 layouts 文件夹下有模板,Hugo 就会用用户的 layouts 覆盖主题的 layouts,这就是 Hugo 强大的自定义的方式。因此在开始前,不妨先复制一份 themes/blowfish/layouts/partials/header 文件夹到 layouts/partials/header 并新建一个名为 island.html 的文件。本自定义基于 fixed.html 模板,所以你可以直接将 fixed.html 复制进去(还可以把里面引用的 basic.html 也复制进去,如果你不想污染原有的文件的话)。 别忘了在 params.toml 中把菜单样式名改为 island!

如果不想折腾,可以在文末获取源码,直接粘贴到对应目录即可。

圆角和宽度
#

这里就要得益于好用的https://tailwindcss.com/了,从此页面布局和样式可以不在几个窗口之间跳来跳去。这里直接上样式代码!

不过,Blowfish 并没有携带完整的 Tailwind CSS 包,所以你还需要在 assets/css/custom.css 中放入你缺少的样式。需要的文件在文末

<div id="island-header"
  class="fixed rounded-full place-self-center top-4 pl-[24px] pr-[24px] w-full min-w-se max-w-se se:max-w-sm sm:max-w-md md:max-w-xl lg:max-w-2xl"
  style="z-index: 100;">
  <div id="menu-blur"
    class="absolute rounded-full opacity-0 inset-x-0 h-full nozoom shadow-2xl backdrop-blur-2xl shadow-lg">
  </div>
  <div ...>
  </div>
</div>

这里我加了一个 id="island-header" 方便后面调用。

滚动切换标题
#

这个功能其实是从 https://squidfunk.github.io/mkdocs-material/ 抄来的,算是 Material for MkDocs 的一个特性。在原来的代码里,它是这么写的:

<!-- header.html -->

<!-- Header title -->
<div class="md-header__title" data-md-component="header-title">
  <div class="md-header__ellipsis">
    <div class="md-header__topic">
      <span class="md-ellipsis">
        {{ config.site_name }}
      </span>
    </div>
    <div class="md-header__topic" data-md-component="header-topic">
      <span class="md-ellipsis">
        {% if page.meta and page.meta.title %}
          {{ page.meta.title }}
        {% else %}
          {{ page.title }}
        {% endif %}
      </span>
    </div>
  </div>
</div>
// header/title/index.ts
/**
 * Watch header title
 *
 * @param el - Heading element
 * @param options - Options
 *
 * @returns Header title observable
 */
export function watchHeaderTitle(
  el: HTMLElement, { viewport$, header$ }: WatchOptions
): Observable<HeaderTitle> {
  return watchViewportAt(el, { viewport$, header$ })
    .pipe(
      map(({ offset: { y } }) => {
        const { height } = getElementSize(el)
        return {
          active: y >= height
        }
      }),
      distinctUntilKeyChanged("active")
    )
}

/**
 * Mount header title
 *
 * This function swaps the header title from the site title to the title of the
 * current page when the user scrolls past the first headline.
 *
 * @param el - Header title element
 * @param options - Options
 *
 * @returns Header title component observable
 */
export function mountHeaderTitle(
  el: HTMLElement, options: MountOptions
): Observable<Component<HeaderTitle>> {
  return defer(() => {
    const push$ = new Subject<HeaderTitle>()
    push$.subscribe({

      /* Handle emission */
      next({ active }) {
        el.classList.toggle("md-header__title--active", active)
      },

      /* Handle complete */
      complete() {
        el.classList.remove("md-header__title--active")
      }
    })

    /* Obtain headline, if any */
    const heading = getOptionalElement(".md-content h1")
    if (typeof heading === "undefined")
      return EMPTY

    /* Create and return component */
    return watchHeaderTitle(heading, options)
      .pipe(
        tap(state => push$.next(state)),
        finalize(() => push$.complete()),
        map(state => ({ ref: el, ...state }))
      )
  })
}
// Header title in active state, i.e. page title is visible
&--active .md-header__topic {
  z-index: -1;
  pointer-events: none;
  opacity: 0;
  transition:
    transform 400ms cubic-bezier(1, 0.7, 0.1, 0.1),
    opacity   150ms;
  transform: translateX(px2rem(-25px));
}

// Second header topic - title of the current page
+ .md-header__topic {
  z-index: 0;
  pointer-events: initial;
  opacity: 1;
  transition:
    transform 400ms cubic-bezier(0.1, 0.7, 0.1, 1),
    opacity   150ms;
  transform: translateX(0);
}

只摘了一部分,但也大概可以说明它的一个原理了。

1. 一开始两个标题是都叠放在一起的,这一步要通过 css 的绝对定位实现;
2. 默认情况下,网站名称可见,页面标题不可见,不可见的元素的 `z-index: -1; opacity: 0;` ;
3. 检测滚动,当向下滚动到一级标题下方的时候,触发切换,给 header_title 加一个 active;
4. 对于这个 active 类,让 css 处理动画,使用 `transitions` 和 `transforms` ;
5. 当向上滚动回到顶部的时候,把 active 类去掉,再处理动画。

其实很简单,但是我前端没怎么学过,所以实现起来也是费了一些功夫。首先需要找到菜单栏对应的标签,给它一个 id 这样我们在写 JS 的时候方便操控它。直接搜索 .Site.Title 就可以找到这个对应的标签。给上一层 nav 加上一个 id。

QQ_1728484641587.png

由于要分别定义两个标题的样式,所以两个标题也得上 id。方便起见这里直接提供我的代码:

  <nav id="title-bar" class="hidden se:flex md:ml-7">
    {{ if not .Site.Params.disableTextInHeader | default true }}
    <a id="page-title-bar" href="{{ "" | relLangURL }}"
      class="relative text-base font-bold text-gray-500 hover:text-gray-900">{{-
      .Site.Title | markdownify
      -}}</a>
    <a id="article-title-bar" href="{{ "#the-top" }}"
      class="flex text-base font-bold text-gray-500 hover:text-gray-900">{{-
      .Title | markdownify
      -}}
    </a>
    {{ end }}
  </nav>

对应的样式:

#page-title-bar, #article-title-bar {
  transition: opacity 400ms, transform 400ms;
}

#page-title-bar {
  opacity: 1;
  transform: translateX(0);
}

#article-title-bar {
  opacity: 0;
  transform: translateX(20px);
}

.title-switched #page-title-bar {
  opacity: 0;
  transform: translateX(-20px);
}

.title-switched #article-title-bar {
  opacity: 1;
  transform: translateX(0);
}

然后就可以开始实现逻辑了!

let lastScroll = 0;
let isArticleTitleVisible = false;

window.addEventListener("scroll", function (e) {
  const scroll = window.pageYOffset || document.documentElement.scrollTop;
  const scrollDirection = scroll > lastScroll ? "down" : "up";

  const title_bar = document.getElementById("title-bar");
  const heading = document.querySelector("h1");

  var background_blur = document.getElementById('menu-blur');
  background_blur.style.opacity = (scroll / 300);

  if (heading && window.location.pathname !== "/") {
    const headingRect = heading.getBoundingClientRect();
    const headingOffset = headingRect.bottom + scroll;
    const switchPoint = headingOffset - 200; // 切换点
    const restorePoint = headingOffset + 100; // 恢复点

    if (scrollDirection === "down" && scroll > switchPoint && !isArticleTitleVisible) {
      title_bar.classList.add("title-switched");
      isArticleTitleVisible = true;
    } else if (scrollDirection === "up" && scroll < restorePoint && isArticleTitleVisible) {
      title_bar.classList.remove("title-switched");
      isArticleTitleVisible = false;
    }
  }

  lastScroll = scroll <= 0 ? 0 : scroll; // 处理滚动到顶部的情况
});

这里和 h1 标题配合的关键就在于 headingOffset 的获取。switchPoint 和 restorePoint 可以自己适当前移后移挑个合适的时机。

随滚动深度增加模糊效果
#

其实这个已经实现完了。

就在上面的代码里,有一个

var background_blur = document.getElementById('menu-blur');
background_blur.style.opacity = (scroll / 300);

大道至简呐!

下划隐藏和上划显示
#

这个我一开始也是用的 translateY,但是发现 translate / transform 天生和 fixed 元素八字不合啊,用了的话我的手机菜单页就得失效了,于是用了一个和滚动深度控制类似的办法。

原理是这样的:

1. 设定阈值,当页面下滑超过阈值时允许下划隐藏菜单栏、上划显示;
2. 对于下划隐藏,给予延迟效果,即累积下划量到一定值后,菜单栏继续随下划上移出屏幕;
3. 一旦累计下划被上划打断,重新累积;
4. 上划显示不太需要延迟,可以立即响应。

所以核心在于判断划动方向和划动量。还有一点很关键,就是菜单栏的位置变化得是连续的,所以如果把它看作一个累计下划量的函数就不太合适。我是拿每次划动量来做的。上代码!

let lastScrollTop = 0;
let lastScrollDirection = 0;
let island_header_style_top = 1;
let accumulated_scroll = 0;

window.addEventListener("scroll", function (e) {
  const scroll = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0;
  let currScrollDirection = scroll - lastScrollTop > 0 ? 1 : -1; // 1 for down, -1 for up

  const island_header = document.getElementById("island-header");
  const heading = document.querySelector("h1");
  const headingDistance = heading.getBoundingClientRect();
  const headingOffset = headingDistance.bottom + scroll;
  const threshold = headingOffset * 2;

  if (scroll > headingOffset) {
    if (currScrollDirection !== lastScrollDirection) {
      accumulated_scroll = 0;
    } else {
      accumulated_scroll += Math.abs(scroll - lastScrollTop);
    }

    if ((accumulated_scroll > threshold || currScrollDirection == -1) && (scroll > headingOffset + threshold / 2)) {
      island_header_style_top = Math.max(
        -4.5,
        Math.min(
          1,
          (island_header_style_top - ((scroll - lastScrollTop) / 12) ** 3)
        )
      );

      island_header.style.top = island_header_style_top + "rem";
    }
  }

  lastScrollTop = scroll;
  lastScrollDirection = currScrollDirection;
});

这里的 island-header 指的就是 menu-blur 上一层的真正的菜单栏。其实也非常朴实,跟前面的差不多,有点像是写什么课程作业了哈哈哈。


文件
#

别忘了在 params.toml 中把菜单样式名改为 island!

希望你喜欢!

博客的自定义 - 这篇文章属于一个选集。
§ 2: 本文