JavaScript Library – Swiper
前言
官网已经有很好的教程了, 这篇只是记入一些我用过的东西和冷门知识.
参考
安装
yarn add swiper
JS
import Swiper from 'swiper'; // core js import { Navigation } from 'swiper/modules'; // modules js import 'swiper/css'; // core css import 'swiper/css/navigation'; // module css const swiper = new Swiper('.swiper', { modules: [Navigation], // modules // config navigation: { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev', }, });
它的调用是 import js & css from node_modules
有分 core module 和其它 modules (比如 navigation, pagination 等等, 都是按需加载的)
setup 就是 import modules 和 configuration.
HTML
<div class="swiper"> <div class="swiper-wrapper"> <div class="swiper-slide">slide1</div> <div class="swiper-slide">slide2</div> </div> <div class="swiper-button-next"></div> <div class="swiper-button-prev"></div> </div>
第一层 swiper 就是一个大 container, 自带 margin-auto (居中) 哦
第二层 wrapper 是用来把 slide 横排的, 它是 Flex.
第三层就是所有的 slides 了.
内容就放在 slide 里面. 不建议直接把内容当 slide 来使用, 给它 wrap 一层比较好.
常用 config
左右箭头
JS
import Swiper from 'swiper'; import { Navigation } from 'swiper/modules'; import 'swiper/css'; import 'swiper/css/navigation'; new Swiper('.swiper', { modules: [Navigation], navigation: { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev', }, });
注: 要 import CSS 哦
Pagination
HTML
JS
import Swiper from 'swiper'; import { Pagination } from 'swiper/modules'; import 'swiper/css'; import 'swiper/css/pagination'; new Swiper('.swiper', { modules: [Pagination], pagination: { el: '.swiper-pagination', clickable: true, // 默认是不可点击换 slide 的哦 dynamicBullets: true, // 开启的话 bullet 就不会出现到完, 好处是干净, 坏处是用户不知道总共有多少 slide }, });
无限循环
JS
loop: true,
Autoplay 自动播放
import Swiper from 'swiper'; import { Autoplay } from 'swiper/modules'; new Swiper('.swiper', { modules: [Autoplay], autoplay: { delay: 2500, disableOnInteraction: false, pauseOnMouseEnter: true, }, });
它需要 import module 哦, 有一些交互暂停的配置
如果开启 pauseOnMouseEnter + disableOnInteraction 的话, mouse enter 后就 autoplay 就 stop 了而且不会恢复
如果只是开启 pauseOnMouseEnter 那只是 mouse enter 暂停, mouse leave 就恢复了
Slide per view / group
红色是 1 个 view, 绿色是一个 slide, 可以指定 1 个 view 里面出现都少个 slide. 它会自动去 set slide 的 width.
slidesPerView: 5, // 支持小数点哦, 2.5 表示 show 两个半 slide
spaceBetween: 30,
slidesPerGroup: 5
space between 就是 gap, 间距. unit 是 px
slidesPerGroup 是指 swipe 一下移动多少, 默认是 1 slide. 通常像上面这种会把 group 和 view set 成同等, 那么每 swipe 一次就是一整个 view 换到完.
gap can't swipe issue
目前有一个 issue, 虽然已经 close 了, 但显然还没有 fixed.
注意看, 第一个 view 的 gap 是可以 swipe 的, 但是第二个 view 开始 gap 就不可以 swipe 了.
目前没有什么好的 workaround, 用 padding 做的话需要一些小算法, 不是很好管理, 还是等等看它吧.
Breakpoint
breakpoints: { 640: { slidesPerView: 2, spaceBetween: 20, }, 768: { slidesPerView: 4, spaceBetween: 40, }, 1024: { slidesPerView: 5, spaceBetween: 50, }, },
不同 breakpoint 下用不同的切换不同的 config
Auto Height
每个 slide 的高度可能不一样, 默认体验是拿最高的 slide 作为整个 swiper 的高度. 但这样可能一开始的时候会比较难看.
所以就有了 auto height. 它会在 change view 的时候拿当前 view 最高的 slide heigth 作为 swiper 高度, 是一个动态 set height 的效果.
new Swiper(swiperElement, { autoHeight: true, });
效果
dynamic content auto height
有时候会遇到动态内容, 那可以调用 JS API 来解决
swiper.addEventListener('ellipsisopen', () => {
swiperInstance.updateAutoHeight();
});
效果
init auto height with font family loaded
还有一种情况是 font family load 的慢, 也可能会造成 auto height 计算不准哦, 可以监听 font 加载完成后去 udpate 一下.
document.fonts.ready.then(() => {
swiperInstance.updateAutoHeight();
});
rounding issue
注意看, 第一个卡片的 border 不见了.
原因是 auto height 会 rounding. 把本来的 432.344px round 成了 432px 而已. 所以 border 被吃掉了 (我没有翻源码. 只是推测而已)
解决方法是在 swiper element 添加 overflow-y visible.
这样内容就可以超出范围了. 不用担心会超过太多, 因为 swiper 已经做了 auto height 计算, 高度已经是最大值了.
连续 Next 体验问题
auto height 每次换 slide 时都会改变 Swiper 高度,如果 prev next button 依赖于这个高度,那体验就会被影响。
上面例子中,我无法联系的按 next。解决思路有 2 个方向。
第一,prev next 不要依赖 slide 的高度。比如移到 Swiper 左边,而不是下面。(但有时候空间太少,真的没有地方可以放。)
第二,让这个 auto height 慢一点触发。比如 next 了 1 秒后才 resize。
要达到这个效果,Swiper 没有开放任何配置,我们只能靠 hack。
首先关闭 autoHeight
const swiperInstanace = new Swiper('.swiper', { autoHeight: false, });
然后模拟 auto height
swiperInstanace.el.classList.add('swiper-autoheight');
Swiper 的结构是 swiper > wrapper > slide
wrapper display flex + slide height 100% 导致了每个 slide 有不同的高度。
其实这里有点奇妙,通常我们会用 align-items: flex-start + slide height auto 来实现,而不是 slide height 100%。
而 .swiper-autoheight 做的事情就是把 height 100% 换成 auto 和加上 align-items: center。
这 2 种实现最大的区别在于当 wrapper height 小的时候,slide 的 offset-height 是否会跟着变小,100%就会,auto 则不会。
而 auto height 我们需要拿 slide 的 offset-height 所以它当然不应该变小咯。
接着监听 slide change 然后 delay 执行 updateAutoHeight 就可以了。
swiperInstanace.updateAutoHeight(); // for first load fromEvent(swiperInstanace, 'slideChangeTransitionEnd') .pipe(debounceTime(500)) .subscribe(() => { swiperInstanace.updateAutoHeight(1000); });
这里用了 RxJS 来写,代码比较干净。它做的事情就是监听 slideChangeTransitionEnd
每一次触发后只要 500ms 内没有再触发,那就调用 updateAutoHeight,如果一直触发,那就等到它稳定。
这样就实现了一直 next...等待 500ms 后 resize。
Navigation outside container
Swiper 默认是把 navigation 放到 container 里面的. 有时候它会被挡住, 这样很不好看.
HTML
JS
new Swiper('.swiper', { modules: [Navigation], navigation: { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev', }, slidesPerView: 4, spaceBetween: 40, });
解决方案
stackoverflow – CSS - How to have swiper slider arrows outside of slider that takes up 12 column row
它只给了一个思路, 把 navigation 放到 container 外面.
但这样是不够的, prev, next 是绝对定位, 必须在加上一个 wrapper 才可以
添加一个 swiper-container, 把 prev, next 移到外面
<div class="swiper-container">
<div class="swiper">
<div class="swiper-wrapper">
<div class="swiper-slide">
<img src="../images/brand-logo/carrier.jpg" />
</div>
</div>
</div>
<div class="swiper-button-next"></div>
<div class="swiper-button-prev"></div>
</div>
CSS Style
.swiper-container { position: relative; padding-inline: 6rem; width: 50%; margin-inline: auto; .swiper { .swiper-wrapper { .swiper-slide { img { max-width: 100%; } } } } }
关键是让 container 取代 .swiper (提醒: container position 必须是 relative 哦, 这样 button-next/prev 才能定位到)
最终效果:
绿色部分就是 padding 的作用了.
Pagination 也一样
pagination 也有同样的问题, 解决方案也是一样的, 搞 swiper-container 做 padding-bottom
后来我发现, pagination 视乎并没有这个问题. slide 的高度不会覆盖 padding bottom, 所以只要在 swiper 加 padding-bottom 就可以了. 不需要搞 swiper-container.
当遇到 multiple swiper
如果有超过 1 个 swiper. 上面这个 container 方案会失效. 原因是 selector 查找是往 child 找的
当我们把 pagination 移出 .swiper 以后, 如果整个 page 只有一个 swiper 它还能找到, 多个就找错了.
所以需要修改 JS, 自己 select 然后把 element 传给 Swiper
const swiperContainers = document.querySelectorAll('.swiper-container'); for (const container of swiperContainers) { const swiper = container.querySelector<HTMLElement>('.swiper')!; const pagination = container.querySelector<HTMLElement>('.swiper-pagination')!; new Swiper(swiper, { modules: [Pagination], pagination: { el: pagination, }, }); }
当遇到 override style
通过 CSS Variables 修改 style, 本来 variables 是一定在 .swiper 的 (.swiper-pagination 的 parent)
但这里移出来了, 所以 variables 要改成定义在它的 parent .swiper-container 哦.
Hero Banner Image & Text & Animation
看这篇 CSS & JS Effect – Hero Banner Swiper
和 Swiper 有关的地方是, 当 slide active 时, swiper 会给 slide element 一个 swiper-slide-active class
Text Selection
参考: stackoverflow – how to enable select text in swiper.js
默认情况下, swiper 是 select 不到 slide 中的 text 的, 但是它的 cursor 却会误导用户哦.
解决方法 1 (推荐) : cursor: default
这样就明确让用户知道, text 无法被选择.
解决方法 2:
wrap 一层 span 加上 class swiper-no-swiping, 这样 text 就不能 swiping 同时可以 select 了
解决方法 3:
完全禁止 swiping, 只能通过 navigation 换 slide
Nested Swiper
Child A 无法被 swipe,因为它是 nested Swiper。
解决方法非常简单,只要在 Child Swiper 添加 Config 就可以了。
new Swiper(childSwiperElement, { nested: true });
效果
Multiple Swiper in Page
参考: stackoverflow – How can I have multiple "Swiper" slideshows on a single page
<div class="swiper swiper1"> <div class="swiper-wrapper"> <div class="swiper-slide">slide1</div> <div class="swiper-slide">slide2</div> </div> <div class="swiper-button-next"></div> <div class="swiper-button-prev"></div> </div> <div class="swiper swiper2"> <div class="swiper-wrapper"> <div class="swiper-slide">slide1</div> <div class="swiper-slide">slide2</div> </div> <div class="swiper-button-next"></div> <div class="swiper-button-prev"></div> </div>
在最外头的 element 加上一个别名 class name, 比如 swiper1, swiper2. 原来的 swiper 依据要放哦.
swiper 里面就不需要别名了, 比如 swiper-wrapper, slide, button-next 等都不需要
JavaScript
new Swiper('.swiper1', { modules: [Navigation], navigation: { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev', }, }); new Swiper('.swiper2', { modules: [Navigation], navigation: { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev', }, });
也是最外头的 selector 需要选中别名就可以了. nextEl .swiper-button-next 不需要.
Tips: 也可以用 child selector 比如 '.hero-banner-section > .swiper' 这样也是可以 selector 到正确的, 而且不需要别名. 看个人管理习惯呗.
Override Style
swiper 的 style 大部分都是用 CSS variable 做的. 这里举一个 override pagination 的例子.
CSS Style
override CSS Style
.swiper { --swiper-pagination-bullet-size: 100px; --swiper-pagination-bullet-width: 100px; --swiper-pagination-bullet-height: 100px; --swiper-theme-color: red; --swiper-pagination-color: red; }
这几个变量都是有效的. 要直接 override by selector 也是可以
.swiper { .swiper-pagination-bullet { background-color: red; } }
效果
Fully Custom Pagination
效果
主要就是写一些交互代码就可以了.
HTML
<div class="container"> <div class="swiper"> <div class="swiper-wrapper"> <div class="swiper-slide"> <img src="./images/tifa1.jpg" width="16" height="9" /> </div> <div class="swiper-slide"> <img src="./images/tifa2.jpg" width="16" height="9" /> </div> <div class="swiper-slide"> <img src="./images/tifa3.jpg" width="16" height="9" /> </div> </div> </div> <div class="bullet-list"> <div class="bullet active"> <img src="./images/tifa1.jpg" width="16" height="9" /> </div> <div class="bullet"> <img src="./images/tifa2.jpg" width="16" height="9" /> </div> <div class="bullet"> <img src="./images/tifa3.jpg" width="16" height="9" /> </div> </div> </div>
CSS Style
.container { width: 400px; .bullet-list { margin-top: 1rem; display: flex; gap: 8px; .bullet { cursor: pointer; width: 75px; border-radius: 4px; overflow: hidden; &.active { outline: 2px solid red; } } } }
没什么特别的, 做一些排版而已.
JavaScript (重点来了)
参考:
import Swiper from 'swiper'; const bullets = document.querySelectorAll('.bullet'); const swiper = new Swiper('.swiper', { on: { slideChange: (swiper) => { bullets.forEach((bullet) => bullet.classList.remove('active')); bullets.item(swiper.activeIndex).classList.add('active'); }, }, }); bullets.forEach((bullet, index) => { bullet.addEventListener('click', () => { swiper.slideTo(index); }); });
不需要 引入 Pagination Module.
1. 监听 slideChange event, 当用户 slide swiper 的时候更新 bullet style. (用到了 Swiper event)
2. 当用户 click bullet 的时候更新 swiper. (用到了 Swiper method)
提醒:
1. item activeIndex = 0 = first item (这个符合 JS 习惯)
2. 如果 Swiper 开启 loop 模式, 它会有 duplicated 的 slide, 而这些 slide index 是继续增长的.
比如, 有 3 个 slide, [0, 1, 2], 因为 loop 它 duplicated 了 1 个 slide, 那么变成 [0, 1, 2, 3]
当遇上 slidesPerGroup
上面给的例子是比较简单的, slide 和 bullet 的数量一致. 如果遇到有 slidesPerGroup 的情况会比较麻烦一些.
参考 swiper 原生 pagination 的实现方法, 可以看出它是动态创建 bullet 的. 因为 bullet 的数量不仅仅依赖 slide 的数量,
同时也依赖各种配置, 比如 slidesPerGroup, dynamicBullets 等等.
这里我给出一个 slidesPerGroup 的实现方式. 原理依然是用上面教的底层 API, 只是加了一些计算而已.
const swiper = new Swiper('.swiper', { slidesPerView: 2, spaceBetween: 16, slidesPerGroup: 2, }); // 从 swiper.slides 中获取图片当缩率图 const slideImages = swiper.slides.map(slide => slide.querySelector('img')!); // 依据 slidesPerGroup 选出呈现的图片第 1 3 5 张 const bulletImages = slideImages.filter((_, index) => index % swiper.params.slidesPerGroup! === 0); // 创建 bullet 和绑定事件 const bullets = bulletImages.map((bulletImage, index) => { const bullet = document.createElement('div'); bullet.classList.add('bullet'); // 这里也是需要计算 if (index === swiper.activeIndex / swiper.params.slidesPerGroup!) { bullet.classList.add('active'); } bullet.appendChild(bulletImage.cloneNode(true)); bullet.addEventListener('click', () => { // 这里也是要计算 swiper.slideTo(index * swiper.params.slidesPerGroup!); }); return bullet; }); const bulletList = document.querySelector('.bullet-list')!; bullets.forEach(bullet => bulletList.appendChild(bullet)); swiper.on('slideChange', swiper => { bullets.forEach(bullet => bullet.classList.remove('active')); // 这里也是要计算 bullets[swiper.activeIndex / swiper.params.slidesPerGroup!].classList.add('active'); });
补上一个 breakpoint + RxJS 的版本
<div class="custom-swiper-pagination"> <template><div class="bullet"></div></template> </div> const swiper = new Swiper('.swiper', { breakpoints: { 768: { slidesPerView: 2, spaceBetween: 16, slidesPerGroup: 2, }, }, }); const slidesPerGroup$ = fromEvent(swiper, 'breakpoint').pipe( map(() => swiper.params.slidesPerGroup!), startWith(swiper.params.slidesPerGroup!), distinctUntilChanged() ); const pagination = document.viewChild('.custom-swiper-pagination'); const bulletTemplate = pagination.viewChild<HTMLTemplateElement>('template'); let bullets: Element[] = []; slidesPerGroup$.subscribe(slidesPerGroup => { bullets.forEach(bullet => pagination.removeChild(bullet)); bullets = swiper.slides .filter((_, index) => index % slidesPerGroup === 0) .map((_, index) => { const frag = bulletTemplate.content.cloneNode(true) as DocumentFragment; const bullet = frag.firstElementChild!; if (index === swiper.activeIndex / slidesPerGroup) { bullet.classList.add('active'); } bullet.addEventListener('click', () => { swiper.slideTo(index * slidesPerGroup); }); pagination.appendChild(frag); return bullet; }); }); swiper.on('slideChange', () => { bullets.forEach(bullet => bullet.classList.remove('active')); bullets[swiper.activeIndex / swiper.params.slidesPerGroup!].classList.add('active'); });
当遇上 dynamicBullets
dynamic bullets 用于当 bullet 非常多的时候, 它可以只显示一部分。有点小 slider 的感觉.
它的实现主要靠 2 招
1. .prev-prev, .prev, .active, .next, .next-next
这 5 个 class 就是主要的 design
2. translateX
移动是靠 translateX 完成的.
HTML<div class="pagination"> <div class="bullet-list"> <div class="bullet active"></div> <div class="bullet next"></div> <div class="bullet next-next"></div> <div class="bullet"></div> <div class="bullet"></div> <div class="bullet"></div> <div class="bullet"></div> <div class="bullet"></div> <div class="bullet"></div> <div class="bullet"></div> </div> </div> <div class="action"> <button class="prev-btn">prev</button> <button class="next-btn">next</button> </div>
CSS
.pagination { // 一些变量控制 design --bullet-size: 2rem; --gap: 0.5rem; $bullet-count: 5; --bullet-count: #{$bullet-count}; // 因为 CSS 没有 Math.Floor 只好用 Sass 替代 --bullet-half-count: #{math.floor(calc($bullet-count / 2))}; --active-index: 0; margin-top: 3rem; outline: 1px solid blue; // 计算 display width (依据 bullet 大小, 数量, 间距) width: calc( (var(--bullet-count) * var(--bullet-size)) + ((var(--bullet-count) - 1) * var(--gap)) ); overflow-x: hidden; margin-inline: auto; .bullet-list { display: flex; gap: var(--gap); // 计算 translateX (依据 bullet 大小, 间距, 当前 active index) transform: translateX( calc( (var(--bullet-half-count) * var(--bullet-size) + var(--bullet-half-count) * var(--gap)) - (var(--active-index) * var(--bullet-size) + var(--active-index) * var(--gap)) ) ); transition: transform 0.4s; .bullet { flex-shrink: 0; width: var(--bullet-size); aspect-ratio: 1 / 1; border-radius: 50%; background-color: pink; &.active { background-color: red; } &.prev, &.next { transform: scale(0.66); } &.prev-prev, &.next-next { transform: scale(0.33); } transition-property: background-color, transform; transition-duration: 0.4s; } } } .action { margin-top: 2rem; display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; margin-inline: auto; width: max-content; button { background-color: red; color: white; padding: 1rem 2rem; } }
JS
代码比较碎, 但其实只是做了 2 件事
1. set .prev-prev, .prev, .active, .next, .next-next
2. set CSS variable --active-index
const pagination = document.querySelector<HTMLElement>('.pagination')!; const bullets = Array.from(pagination.querySelectorAll('.bullet')); function prevNext(prevOrNext: 'prev' | 'next'): void { const prevNextMethods = { prev: getPrevNIndex, next: getNextNIndex, }; const prevNextMethod = prevNextMethods[prevOrNext]; const activeIndex = bullets.findIndex(bullet => bullet.classList.contains('active')); const currIndex = prevNextMethod(bullets, activeIndex); bullets.forEach(bullet => ['prev-prev', 'prev', 'active', 'next', 'next-next'].forEach(className => bullet.classList.remove(className) ) ); bullets[currIndex].classList.add('active'); tryAddPrevOrNext('prev', 1); tryAddPrevOrNext('prev', 2); tryAddPrevOrNext('next', 1); tryAddPrevOrNext('next', 2); pagination.style.setProperty('--active-index', currIndex.toString()); function tryAddPrevOrNext(prevOrNext: 'prev' | 'next', count: number): void { const prevNextMethod = prevNextMethods[prevOrNext]; let loopIndex = currIndex; for (let i = 0; i < count; i++) { const index = prevNextMethod(bullets, loopIndex, { mode: 'null' }); if (index === null) return; loopIndex = index; if (i === count - 1) { bullets[loopIndex].classList.add( Array(i + 1) .fill(prevOrNext) .join('-') ); } } } } const nextBtn = document.querySelector('.next-btn')!; const prevBtn = document.querySelector('.prev-btn')!; nextBtn.addEventListener('click', () => prevNext('next')); prevBtn.addEventListener('click', () => prevNext('prev')); // helper 方法 // 告诉它当前你在第几个, 然后想 next 多少个, 当超过的时候它会帮你 loop or 返回 null export function getNextNIndex( lengthOrItems: number | unknown[], currIndex: number, config?: { n?: number; mode?: 'loop' | 'max' } ): number; export function getNextNIndex( lengthOrItems: number | unknown[], currIndex: number, config?: { n?: number; mode?: 'null' } ): number | null; export function getNextNIndex( lengthOrItems: number | unknown[], currIndex: number, config?: { n?: number; mode?: 'loop' | 'max' | 'null' } ): number | null { const length = Array.isArray(lengthOrItems) ? lengthOrItems.length : lengthOrItems; const { n = 1, mode = 'loop' } = config ?? {}; const next = currIndex + n; if (mode === 'max' && next > length - 1) { return length - 1; } else if (mode === 'null' && next > length - 1) { return null; } return next % length; } // helper 方法 // 告诉它当前你在第几个, 然后想 prev 多少个, 当超过的时候它会帮你 loop or 返回 null export function getPrevNIndex( lengthOrItems: number | unknown[], currIndex: number, config?: { n?: number; mode?: 'loop' | 'max' } ): number; export function getPrevNIndex( lengthOrItems: number | unknown[], currIndex: number, config?: { n?: number; mode?: 'null' } ): number | null; export function getPrevNIndex( lengthOrItems: number | unknown[], currIndex: number, config?: { n?: number; mode?: 'loop' | 'max' | 'null' } ): number | null { const length = Array.isArray(lengthOrItems) ? lengthOrItems.length : lengthOrItems; const { n = 1, mode = 'loop' } = config ?? {}; const prev = currIndex - n; if (mode === 'max' && prev < 0) { return 0; } else if (mode === 'null' && prev < 0) { return null; } return (length + (prev % length)) % length; }
最后送上一个完成版本 RxJS + breakpoint + slidesPerGroup + dynamicBullets
HTML
结构 pagination > bullet-list > bullet
bullet-list 负责 translateX
bullet 负责小 design, 比如 .active 高亮, .prev scale(0.66)
<div class="swiper"> <div class="swiper-wrapper"> <div class="swiper-slide"> <img src="../images/tifa1.jpg" width="16" height="9" /> </div> <div class="swiper-slide"> <img src="../images/tifa2.jpg" width="16" height="9" /> </div> <div class="swiper-slide"> <img src="../images/tifa3.jpg" width="16" height="9" /> </div> <div class="swiper-slide"> <img src="../images/tifa1.jpg" width="16" height="9" /> </div> <div class="swiper-slide"> <img src="../images/tifa2.jpg" width="16" height="9" /> </div> <div class="swiper-slide"> <img src="../images/tifa3.jpg" width="16" height="9" /> </div> <div class="swiper-slide"> <img src="../images/tifa1.jpg" width="16" height="9" /> </div> <div class="swiper-slide"> <img src="../images/tifa2.jpg" width="16" height="9" /> </div> <div class="swiper-slide"> <img src="../images/tifa3.jpg" width="16" height="9" /> </div> <div class="swiper-slide"> <img src="../images/tifa1.jpg" width="16" height="9" /> </div> <div class="swiper-slide"> <img src="../images/tifa2.jpg" width="16" height="9" /> </div> <div class="swiper-slide"> <img src="../images/tifa3.jpg" width="16" height="9" /> </div> <div class="swiper-slide"> <img src="../images/tifa1.jpg" width="16" height="9" /> </div> <div class="swiper-slide"> <img src="../images/tifa2.jpg" width="16" height="9" /> </div> <div class="swiper-slide"> <img src="../images/tifa3.jpg" width="16" height="9" /> </div> <div class="swiper-slide"> <img src="../images/tifa1.jpg" width="16" height="9" /> </div> <div class="swiper-slide"> <img src="../images/tifa2.jpg" width="16" height="9" /> </div> <div class="swiper-slide"> <img src="../images/tifa3.jpg" width="16" height="9" /> </div> <div class="swiper-slide"> <img src="../images/tifa1.jpg" width="16" height="9" /> </div> <div class="swiper-slide"> <img src="../images/tifa2.jpg" width="16" height="9" /> </div> <div class="swiper-slide"> <img src="../images/tifa3.jpg" width="16" height="9" /> </div> </div> <div class="swiper-pagination"></div> </div> <div class="custom-swiper-pagination"> <div class="bullet-list"></div> </div>
CSS
控制所有 design, width, overflow hidden, translateX, scale 等等
.swiper { --swiper-pagination-bullet-size: 16px; .swiper-pagination { background-color: white; padding-block: 0.5rem; } } .custom-swiper-pagination { --bullet-size: 2rem; --gap: 0.5rem; $bullet-count: 5; --bullet-count: #{$bullet-count}; --bullet-half-count: #{math.floor(calc($bullet-count / 2))}; --active-index: 0; margin-top: 3rem; outline: 1px solid blue; width: calc( (var(--bullet-count) * var(--bullet-size)) + ((var(--bullet-count) - 1) * var(--gap)) ); overflow-x: hidden; margin-inline: auto; .bullet-list { display: flex; gap: var(--gap); transform: translateX( calc( (var(--bullet-half-count) * var(--bullet-size) + var(--bullet-half-count) * var(--gap)) - (var(--active-index) * var(--bullet-size) + var(--active-index) * var(--gap)) ) ); transition: transform 0.4s; .bullet { flex-shrink: 0; width: var(--bullet-size); aspect-ratio: 1 / 1; border-radius: 50%; background-color: pink; &.active { background-color: red; } &.prev, &.next { transform: scale(0.66); } &.prev-prev, &.next-next { transform: scale(0.33); } transition-property: background-color, transform; transition-duration: 0.4s; } } }
JS
负责监听 swiper, 读取 swiper config
动态创建 bullet element, 同时 reset .active, .prev, .next 等 class, 还有 CSS variable --active-index
import Swiper, { Pagination } from 'swiper'; import { distinctUntilChanged, fromEvent, map, startWith } from 'rxjs'; const swiper = new Swiper('.swiper', { modules: [Pagination], pagination: { el: '.swiper-pagination', clickable: true, dynamicBullets: true, }, breakpoints: { 640: { slidesPerView: 2, spaceBetween: 16, slidesPerGroup: 2, }, 1024: { slidesPerView: 3, spaceBetween: 16, slidesPerGroup: 3, }, }, }); const pagination = document.querySelector<HTMLElement>('.custom-swiper-pagination')!; const slidesPerGroup$ = fromEvent(swiper, 'breakpoint').pipe( map(() => swiper.params.slidesPerGroup!), startWith(swiper.params.slidesPerGroup!), distinctUntilChanged() ); const bullets: HTMLElement[] = []; const bulletList = document.querySelector('.bullet-list')!; slidesPerGroup$.subscribe(() => { bullets.forEach(bullet => bullet.parentElement!.removeChild(bullet)); bullets.length = 0; const bulletLength = Math.ceil(swiper.slides.length / swiper.params.slidesPerGroup!); for (let index = 0; index < bulletLength; index++) { const bullet = document.createElement('div'); bullet.classList.add('bullet'); if (typeof swiper.params.pagination === 'object' && swiper.params.pagination.clickable) { bullet.addEventListener('click', () => { swiper.slideTo(index * swiper.params.slidesPerGroup!); }); bullet.style.cursor = 'pointer'; } bullets.push(bullet); } bullets.forEach(bullet => bulletList.appendChild(bullet)); resetActive(bullets, swiper.activeIndex / swiper.params.slidesPerGroup!, pagination); }); swiper.on('slideChange', swiper => { resetActive(bullets, swiper.activeIndex / swiper.params.slidesPerGroup!, pagination); }); function resetActive(bullets: HTMLElement[], activeIndex: number, pagination: HTMLElement): void { const prevNextMethods = { prev: getPrevNIndex, next: getNextNIndex, }; bullets.forEach(bullet => ['prev-prev', 'prev', 'active', 'next', 'next-next'].forEach(className => bullet.classList.remove(className) ) ); bullets[activeIndex].classList.add('active'); tryAddPrevOrNext('prev', 1); tryAddPrevOrNext('prev', 2); tryAddPrevOrNext('next', 1); tryAddPrevOrNext('next', 2); pagination.style.setProperty('--active-index', activeIndex.toString()); function tryAddPrevOrNext(prevOrNext: 'prev' | 'next', count: number): void { const prevNextMethod = prevNextMethods[prevOrNext]; let loopIndex = activeIndex; for (let i = 0; i < count; i++) { const index = prevNextMethod(bullets, loopIndex, { mode: 'null' }); if (index === null) return; loopIndex = index; if (i === count - 1) { bullets[loopIndex].classList.add( Array(i + 1) .fill(prevOrNext) .join('-') ); } } } } export function getNextNIndex( lengthOrItems: number | unknown[], currIndex: number, config?: { n?: number; mode?: 'loop' | 'max' } ): number; export function getNextNIndex( lengthOrItems: number | unknown[], currIndex: number, config?: { n?: number; mode?: 'null' } ): number | null; export function getNextNIndex( lengthOrItems: number | unknown[], currIndex: number, config?: { n?: number; mode?: 'loop' | 'max' | 'null' } ): number | null { const length = Array.isArray(lengthOrItems) ? lengthOrItems.length : lengthOrItems; const { n = 1, mode = 'loop' } = config ?? {}; const next = currIndex + n; if (mode === 'max' && next > length - 1) { return length - 1; } else if (mode === 'null' && next > length - 1) { return null; } return next % length; } export function getPrevNIndex( lengthOrItems: number | unknown[], currIndex: number, config?: { n?: number; mode?: 'loop' | 'max' } ): number; export function getPrevNIndex( lengthOrItems: number | unknown[], currIndex: number, config?: { n?: number; mode?: 'null' } ): number | null; export function getPrevNIndex( lengthOrItems: number | unknown[], currIndex: number, config?: { n?: number; mode?: 'loop' | 'max' | 'null' } ): number | null { const length = Array.isArray(lengthOrItems) ? lengthOrItems.length : lengthOrItems; const { n = 1, mode = 'loop' } = config ?? {}; const prev = currIndex - n; if (mode === 'max' && prev < 0) { return 0; } else if (mode === 'null' && prev < 0) { return null; } return (length + (prev % length)) % length; }
小结论
这种 fully customize 的做法, 我个人觉得非常不划算. 尤其当你仅仅想要修改 design 而且的时候, 它需要自己实现太多功能了.
所以, 我建议, 尽量用 override CSS 的方式去改 design, 要改 HTML 的话就用 renderBullet 方法.
Auto Height and Same Height
问题
这个 slider 有 2 地方不满意.
1. 2 个 slide 高度不一致产生的空白
2. 多个 slide view 高度不一致产生的空白
simple 解决方案
导致两个 slide 不同高度的原因是 swiper slide 的 height 100%
swiper-slide 的 parent 是 swiper-wrapper, wrapper 是 display flex,
按理说 flex items 默认是 stretch 的状态, 但因为 swiper-slide 给了 height 100% override 掉了这个特性. 所以它就变成了 flex-start. (这个我在 Flex 文章说提过)
所以, 想要 same height 可以通过 override swiper-slide 的 height : auto 来实现.
.swiper-slide { height: auto; }
效果
第二个问题: 多个 slide view 高度不一致, 它有 build-in 的解决方案. 那就是用 autoHeight: true
new Swiper('.swiper', { autoHeight: true, });
效果
虽然是解决了第二个问题, 但是第一个问题又出现了. 两个 slide 的高度又不一致了. why?
因为 swiper 在 autoheight 的情况下, 把 swiper-wrapper 的 flex align-items 设置成了 flex-start, 之前是默认的 stretch. (要 stretch + height auto 才可以实现 same height)
如果我们用 CSS override 它, 会发现 autoheight 就 stop working 了...鱼与熊掌, 难以兼得啊...
自定义方案
要保持 same heigh 除了 flex stretch 还有一个简单粗暴的方法. 那就是替 slide 加上 min-height. 通过 JS 计算比较高的 height, 然后 set min-height 到矮的那个 slide.
const slides = Array.from(document.querySelectorAll<HTMLElement>('.swiper-slide')); // 依据 slidesPerView, group 出每一个 view 的 slides const slideGroups = slides.groupToMap((_, index) => Math.floor(index / (swiper.params.slidesPerView as number)) ); for (const [_, groupedSlides] of slideGroups) { // 找出最高的 slide height const maxHeight = Math.max(...groupedSlides.map(el => el.offsetHeight)); // 加上 min height groupedSlides.forEach(el => (el.style.minHeight = `${maxHeight}px`)); }
里头用到了 es2023 的 Array.groupToMap 哦.
效果
这个方案简单, 也容易实现, 而且可以满足 90% 的场景. 但它依然有许多瑕疵.
比如最后一个 slide view 的高度就没有一致. 也没有考虑到 slide resize 的情况.
这里附上一个完整版本, 但我就不解释了. 它只是多了一些繁琐的控制而已.
HTML
<div class="container"> <div class="swiper"> <div class="swiper-wrapper"> <div id="slide1" class="swiper-slide"> <h1>Title</h1> <p>Lorem ipsum dolor sit amet consectetur, adipisicing elit. A, blanditiis?</p> </div> <div id="slide2" class="swiper-slide"> <h1>Title</h1> <p>Lorem ipsum dolor sit amet consectetur adipisicing elit. In molestiae nesciunt ducimus maiores.</p> </div> <div id="slide3" class="swiper-slide"> <h1>Title</h1> <p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Dolorum, itaque aperiam porro perspiciatis vel in repellendus nam nobis voluptas natus.</p> </div> <div id="slide4" class="swiper-slide"> <h1>Title</h1> <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Debitis esse, harum magni quibusdam est, modi rem minus culpa eaque architecto recusandae delectus iste excepturi quis commodi sed? Id tempora qui porro iusto, facilis nihil ut, eum nemo, delectus placeat distinctio.</p> </div> <div id="slide5" class="swiper-slide"> <h1>Title</h1> <p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Dolorum, itaque aperiam porro perspiciatis vel in repellendus nam nobis voluptas natus.</p> </div> <div id="slide6" class="swiper-slide"> <h1>Title</h1> <p>Lorem ipsum, dolor sit amet consectetur adipisicing elit. Debitis tempora dicta esse impedit iusto reprehenderit delectus exercitationem animi corporis ratione.</p> </div> <div id="slide7" class="swiper-slide"> <h1>Title</h1> <p>Lorem ipsum dolor sit amet consectetur, adipisicing elit. A, blanditiis?</p> </div> <div id="slide8" class="swiper-slide"> <h1>Title</h1> <p>Lorem ipsum, dolor sit amet consectetur adipisicing elit. Excepturi, in?</p> </div> <div id="slide9" class="swiper-slide"> <h1>Title</h1> <p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Dolorum, itaque aperiam porro perspiciatis vel in repellendus nam nobis voluptas natus.</p> </div> </div> <div class="swiper-pagination"></div> </div> </div>
CSS
.container { margin-top: 256px; .swiper { --swiper-pagination-bullet-size: 16px; padding-bottom: 4rem; .swiper-slide { padding: 2rem; h1 { font-size: 4rem; margin-bottom: 0.5rem; } p { line-height: 1.5; } &:nth-child(odd) { background-color: hsl(0, 0%, 90%); } &:nth-child(even) { background-color: pink; } } } }
JS 1
这个是用来保持 same height 的
import { EMPTY, Observable, startWith, switchMap } from 'rxjs'; import { assert } from '../module/core/core'; // #region Custom Event export const sameHeightResizeEventName = 'sameheightresize'; export class SameHeightResizeEvent extends CustomEvent<void> { constructor(eventInitDict?: CustomEventInit<void>) { super(sameHeightResizeEventName, { bubbles: true, ...eventInitDict }); } } declare global { interface HTMLElement { addEventListener<K extends typeof sameHeightResizeEventName>( type: K, listener: (this: HTMLElement, ev: SameHeightResizeEvent) => unknown, options?: boolean | AddEventListenerOptions ): void; } } // #endregion End of Custom Event export function setupSameHeight(config?: { container?: Document | HTMLElement; dynamicContent?: boolean; }): void { const { container: root = document, dynamicContent = false } = config ?? {}; const contentChanged$ = dynamicContent ? new Observable(subscriber => { const mo = new MutationObserver(entries => { const addedHaveSameHeight = Array.from(entries[0].addedNodes) .filter((el): el is HTMLElement => el instanceof HTMLElement) .some(el => el.classList.contains('same-height')); const removedHaveSameHeight = Array.from(entries[0].removedNodes) .filter((el): el is HTMLElement => el instanceof HTMLElement) .some(el => (el as HTMLElement).classList.contains('same-height')); if (addedHaveSameHeight || removedHaveSameHeight) { subscriber.next(); } }); mo.observe(root, { childList: true, subtree: true }); return () => mo.disconnect(); }) : EMPTY; contentChanged$ .pipe( startWith(null), switchMap( () => new Observable(() => { const sameHeightElements = Array.from( root.querySelectorAll<HTMLElement>('.same-height') ); const groupChanged$ = new Observable(subscriber => { const mo = new MutationObserver(entries => { if ( entries.some(entry => { assert(entry.target instanceof HTMLElement); return entry.target.dataset.sameHeightGroup !== entry.oldValue; }) ) { subscriber.next(); } }); sameHeightElements.forEach(el => mo.observe(el, { attributes: true, attributeFilter: ['data-same-height-group'] }) ); return () => mo.disconnect(); }); const subscription = groupChanged$ .pipe( startWith(null), switchMap( () => new Observable(() => { const sameHeightGroups = sameHeightElements.groupToMap( el => el.dataset.sameHeightGroup ?? 'default' ); const resizeObservers: ResizeObserver[] = []; for (const [_, sameHeightGroup] of sameHeightGroups) { setSameHeight(sameHeightGroup); const ro = new ResizeObserver(() => { sameHeightGroup[0].dispatchEvent(new SameHeightResizeEvent()); setSameHeight(sameHeightGroup); }); sameHeightGroup.forEach(el => ro.observe(el)); resizeObservers.push(ro); } return () => resizeObservers.forEach(ro => ro.disconnect()); }) ) ) .subscribe(); return () => subscription.unsubscribe(); }) ) ) .subscribe(); function setSameHeight(elements: HTMLElement[]): void { const maxHeight = Math.max(...elements.map(elment => getActualHeight(elment))); for (const element of elements) { const actualHeight = getActualHeight(element); if (actualHeight < maxHeight) { element.style.setProperty('min-height', `${maxHeight}px`); element.dataset.originalHeight = actualHeight.toString(); } else { element.style.removeProperty('min-height'); delete element.dataset.originalHeight; } } function getActualHeight(element: HTMLElement): number { const minHeight = parseFloat(element.style.getPropertyValue('min-height')); if (Number.isNaN(minHeight)) return element.offsetHeight; const originalHeight = parseFloat(element.dataset.originalHeight!); if (element.offsetHeight > minHeight) return element.offsetHeight; return originalHeight; } } }
JS 2
这个是用来对接 swiper 的
import Swiper, { Pagination } from 'swiper'; import { distinctUntilChanged, fromEvent, map, merge } from 'rxjs'; import { setupSameHeight } from '../test/test'; import { assert } from '../module/core/core'; const swiper = new Swiper('.swiper', { modules: [Pagination], pagination: { el: '.swiper-pagination', clickable: true, dynamicBullets: true, }, breakpoints: { 640: { slidesPerView: 2, slidesPerGroup: 2, }, 768: { slidesPerView: 3, slidesPerGroup: 3, }, }, spaceBetween: 16, autoHeight: true, }); const slides = Array.from(document.querySelectorAll<HTMLElement>('.swiper-slide')); slides.forEach(el => el.classList.add('same-height')); setGroupBaseOnSlidesPerView(slides); setupSameHeight(); const slidesPerView$ = fromEvent(swiper, 'breakpoint').pipe( map(() => swiper.params.slidesPerView as number), distinctUntilChanged() ); merge(fromEvent(swiper, 'slideChange'), slidesPerView$).subscribe(() => setGroupBaseOnSlidesPerView(slides) ); swiper.el.addEventListener('sameheightresize', () => swiper.updateAutoHeight()); function setGroupBaseOnSlidesPerView(elements: HTMLElement[]): void { for (let index = 0; index < elements.length; index++) { const element = elements[index]; // group index 是依据 view 里面第一个 slide 的 index // 0 1 2 3 4 5 6 7 8 9 10 11 // 0 0 0 3 3 3 6 6 6 9 9 9 assert(typeof swiper.params.slidesPerView === 'number'); const slidesPerView = swiper.params.slidesPerView; const groupIndex = Math.floor(index / slidesPerView) * slidesPerView; element.dataset.sameHeightGroup = `Group${groupIndex}`; // 特别处理 last slide view (当 slides 不够时, 最后几个 slide 会有 dynamic group, 它 depend on 是 last view or last 2nd view) if (index >= swiper.activeIndex && index < swiper.activeIndex + slidesPerView) { element.dataset.sameHeightGroup = `Group${swiper.activeIndex}`; } } }
效果
Re-order / Sort
没有 re-order 的接口,我们要修改 slide 的位置,可以直接操作 dom。
通过 swiper.slides 找出要移动的 slide element,然后直接 DOM append, prepend, insertAfter 等等。
操作完后调用 swiper.updateSlides() 通知 swiper 就可以了。
Limitation
Only Run Swiper in Certain Breakpoint
参考
Github Issue – Add a way to disable swiper for some breakpoints
Github Issue – Question: How to disable swiper for certain breakpoint only?
Github Issue – How to disable style inline on element swiper-wrapper and swiper-slide
有时候可能希望只有手机使用 Swiper, 电脑不需要. 但 Swiper 并没有很好的 way 去关掉它.
虽然它可以 disable() 或者 destroy() 但是 CSS style, inline style 都不会被清除.
我觉得这条路不顺风水. 如果不要兼顾 resize 的情况或许还可以勉强实现一下.
JS 判断 viewport 决定要不要 init Swiper, 如果不需要就顺便把 Swiper 的 class 清除掉, 比如 .swiper, .swiper-wrapper, .swiper-slide 等等
Flex / Grid Container + Swiper max-width = Bug
不是 100% 确定是 bug, 但我也没有时间去研究了. 这里做一个记入.
上图是一个正常的表现. resize viewport 图片和 paragraph 会放大缩小.
HTML
<div class="container"> <div class="swiper"> <div class="swiper-wrapper"> <div class="swiper-slide"> <img src="./images/tifa1.jpg" width="16" height="9" /> </div> <div class="swiper-slide"> <img src="./images/tifa2.jpg" width="16" height="9" /> </div> <div class="swiper-slide"> <img src="./images/tifa3.jpg" width="16" height="9" /> </div> </div> </div> <p> Lorem ipsum dolor sit amet consectetur adipisicing elit. Maxime quod nam natus velit repellendus? Voluptatem consectetur quam accusantium labore iure ratione voluptate voluptatum harum enim adipisci consequatur facilis nemo qui, voluptates soluta pariatur nihil expedita natus amet cumque impedit id porro. Voluptate sunt natus ipsa nostrum quibusdam reprehenderit, quas harum? </p> </div>
CSS Style
.container { border: 1px solid red; padding: 1rem; .swiper { max-width: 428px; margin-inline: auto; } p { max-width: 640px; margin-inline: auto; } }
关键是 max-width
好, 现在让 container 变成 Flex 或 Grid
结果 bug 出现了
图片超出了 viewport
原因是 Swiper 给的 style width 是 428px, 这不是 max-width 吗?
相关 Github Issue – PSA: Too big/wide slider on initialization
解决方案
给 Swiper 加上 width 100% 就可以破了...没时间去调查原理. 先用着呗.
或者用 width: min(100%, 428px)
Migration
这里记入一下历届版本我遇到的 breaking change.
Swiper 11
参考: Docs Migration Guide to Swiper 11
1. .swiper overflow 从 visible 改成了 hidden,不清楚它为什么要改,但官方说如果这影响了我们的排版,我们 可以 set 成 clip。我自己是 set 成 visible。
2. Swiper Element (Web Component) dispatch 的 event 有 prefix 了。
以前是 slidechange 现在是 swiperslidechange
我之前都没有注意过 Swiper Element,它是 v9 推出的,简单说就是用 Web Component wrapper 了一层,底层依然是 new Swiper。
这是一小段 SwiperContainer 的代码,它是 Custom Elements 来的。