Overview

브랜디 뿐만 아니라 웹 서비스를 하는 많은 회사에서 슬라이더로 Swiperjs(이하 Swiper)를 활용하고 있습니다.

이번에 소개드릴 내용은 IE11을 지원해야 하는 프로젝트에서 Vuejs 2.x 버전을 사용할 경우 Swiper를 설치하는 방법과 Vuejs 내에서 Swiper를 적용할 때 참고해봐도 괜찮을 몇 가지 사례입니다.

(앞으로 22년 6월이면 공식적으로 IE 11에 대한 지원이 종료되지만, 현재는 21년이므로 약 1년 간은 프로젝트에 따라 환경 대응이 필요하신 분들이 있을 수도 있어서 작성하게 되었습니다.)


Contents

  1. 현재의 브랜디 지원 환경
  2. 이제는 브랜디 상황에 맞게 Swiper를 적용해보자
    • 패키지 설치
    • Vuejs 에 적용하기
    • IE 지원 종료 시 패키지 버전 업그레이드 하기
  3. 알아두면 도움 될지도 모를 Swiper 사례
    • 컨텐츠의 수량에 따라 정렬이 달라지는 Swiper
    • 화면 Viewport에 따라 서로 다른 effect를 가진 Swiper

1. 현재의 브랜디 지원 환경

현재 브랜디, 하이버, FMS, 신규 서비스 등 브랜디에서 서비스 중인 혹은 서비스 예정인 컨텐츠들은 Vue 2.x 버전에서 개발되고 있으며, 대다수는 다음과 같이 지원하고 있습니다.

  • Windows, MacOS, iOS (13 이상), AOS (태블릿 미지원)
  • Internet Explorer 10 이상 (현재는 지원이 종료되어 IE 11 이상)
  • Microsoft Edge 11 이상

브랜디에서는 반응형으로 제작된 서비스 및 컨텐츠들이 많은데, 입사할 때만 해도 다양한 슬라이더를 혼용해서 사용하고 있었기 때문에 작년 8월에 이 슬라이더들에 대한 통합 작업을 진행했습니다.

처음에는 Vuejs의 환경에 맞춰서 사용하기 위해 기존에 index.html에 연동한 것을 걷어내고, 패키지를 설치하고 사용하려고 보니..

athena
브랜디는 Vuejs 2.x버전을 사용중이라 대상이 아니라고 하는군요. 유감입니다.


일정이 정해져 있어서 작년에는 아쉬운대로 index.html에 연동된 script는 그대로 두고 html 템플릿을 가져다 사용하여 슬라이더의 대통합을 일궈냈지만 Vue의 환경에 맞지 않게 사용했다는 점에서 심리적 불편함이 느껴졌습니다. 뭔가 ‘어쩔 수 없었다’라며 적당히 타협한 기분이 드는군요.

이를 해소하기 위해서는 브랜디의 환경에 맞춰 Vuejs 2.x 버전에서 IE 11까지 지원하는 방법을 어떻게든 찾아야 합니다.


2. 이제는 브랜디 상황에 맞게 Swiper를 적용해보자.

다행히 최근 헬피와 셀피(구 트랜디) 마이크로페이지 오픈 및 현재 진행중인 신규 서비스 등의 업무를 진행하면서 적용할 수 있게 되어 방법을 공유해보고자 합니다. (babel-polyfill과 같이 ES6에 대한 대응은 이미 설정 되어 있다는 가정하에 작성했습니다.)


2-1. 패키지 설치

Vuejs 2.x 버전에서 IE 11에 대응할 수 있는 Swiper를 사용하려면 다음과 같이 2종의 패키지를 설치 해야 합니다.

npm install swiper@4.4.1 vue-awesome-swiper@3 --save

공식 문서에서 안내한 방식인 npm install swiper vue-awesome-swiper –save 로 설치할 경우, 최신 버전 기준으로 swiper는 6버전, vue-awesome-swiper는 4버전으로 설치가 됩니다.

그러나, Swiper 5버전부터는 IE 브라우저를 지원하고 있지 않기 때문에 IE를 지원하는 Swiper4 버전을 사용하려면 위와 같이 설치하시는 것이 좋습니다. (마찬가지로 vue-awesome-swiper에서는 3버전으로 설치해야 Swiper 4버전에 맞춰 사용할 수 있습니다.)

여기서 “왜 굳이 4.4.1 버전으로 구체적으로 지정한거지?” 궁금하실 분들에게 추가적인 설명을 드리면, 브랜디 쪽 슬라이더 통합 작업을 하면서 Swiper 패키지의 버전업을 동시에 진행하면서 4.x 버전중 가장 최신 버전에 해당하는 4.5.1 을 적용한 케이스가 있었는데, Swiper Thumbs Gallery 기능을 사용할 경우 썸네일 쪽에서 현재 활성화된 swiper-slide를 찾을 수 없는 이슈가 있었습니다. 이처럼 버전을 원복을 하게 된 전례가 있다보니 현재 브랜디에서 사용중인 버전인 4.4.1로 설치할 수 있도록 명령어를 작성했습니다.

만일 해당 기능을 사용하지 않을 경우, swiper@4로 메이저 버전만 지정하셔도 됩니다.

2-2. Vuejs 에 적용하기

Swiper를 적용하려는 컴포넌트에 아래와 같이 넣어줍니다.

// css의 경우, 전역으로 불러오시는 것이 편합니다
import 'swiper/dist/css/swiper.css'
import { swiper, swiperSlide } from 'vue-awesome-swiper'

export default {
  components: {
    swiper,
    swiperSlide
  }
}

2-3. IE 지원 종료 시 패키지 버전 업그레이드 하기

만일 IE 지원이 종료되거나 혹은 프로젝트 내부에서 더 이상 지원을 하지 않기로 결정하게 되어, Swiper 버전을 업그레이드를 하고자 할 경우에는 다음과 같이 설정을 바꿔주시면 됩니다.

npm uninstall swiper vue-awesome-swiper --save
npm install swiper@5.3.7 vue-awesome-swiper --save

패키지 설치 (신규 버전을 설치할 때는 되도록 기존 설치이력을 삭제한 뒤에 재설치를 합시다.)

vue-awesome-swiper 4버전에서는 컴포넌트 이름의 첫 글자가 대문자로 바뀌고, css의 경로 또한 바뀌기 때문에 아래와 같이 설정을 변경합니다. (컴포넌트 이름의 첫글자가 바뀌기 때문에 버전이 바뀌게 될 경우 반드시 컴포넌트들의 이름도 같이 확인해서 변경해주셔야 합니다.)

import { Swiper, SwiperSlide, directive } from 'vue-awesome-swiper'

// import style (<= Swiper 5.x)
// Swiper 6 버전의 경우 현재 Pagination 관련 이슈가 있어 5.3.7 버전으로 설치해야 한다고 합니다.
// 자세한 내용은 아래의 참고자료를 참조하시면 좋을 것 같습니다.
import 'swiper/css/swiper.css'

export default {
  components: {
    Swiper,
    SwiperSlide
  },
  directives: {
    swiper: directive
  }
}

이제는 심리적 불편함과 작별하고 사용할 일만 남았습니다.


3. 알아두면 도움 될지도 모를 Swiper 사례

설치 방법만 안내해드리고 나서 “자, 수고하셨고 이제 각자의 상황에 맞게 Swiper를 써봅시다!” ….라고 마무리 하기엔 뭔가 아쉬운 것 같아서 Swiper 공식 홈에서 소개하는 Demo 버전 사용법 이외에 Vuejs 에서 작업할 때 알아두면 괜찮을 몇 가지 Swiper 사용 사례들을 알려드리려 합니다.

3-1. 컨텐츠의 수량에 따라 정렬이 달라지는 Swiper

💡
OO님, 탭 영역의 전체 너비가 화면보다 작을 경우에는 중앙정렬로 보이게 해주시고
반대로 탭 영역 전체 너비가 화면보다 넘어갈 경우에는 좌측에서부터 정렬한 뒤에 스크롤(or 스와이핑)이 될 수 있도록 구현 가능할까요? 아, 기왕이면 탭 영역이 화면에서 잘리지 않고 연속성 있어 보이게끔 스크롤되는 양 끝 여백에 그라데이션도 넣어주시면 좋을 것 같네요!


06
07

컨텐츠 길이에 맞춰 안정감 있게 보여지는 UI/UX와 관련한 내용은,실제로 업무하다보면 자주 요청사항으로 들어옵니다. 단순히 컨텐츠가 넘치는 상황만 대응한다면 가장 많이 알려져 있는 inline-block 속성을 활용한 정렬이나 요새 많이 사용되는 flexbox로 구현해도 되겠지만, 간혹 여기서 더 나아가 [클릭하면 해당 탭이 좌측으로 자연스럽게 위치할 수 있게 처리해주세요]와 같은 세세한 기능 요청이 들어왔을 경우에는 Swiper가 더 효율적이기도 합니다.

간혹 이런 요구사항에 맞춰 Swiper로 구현하다보면

  1. 우측 맨 끝이랑 화면 끝이 붙어버려서 이슈로 등록된다든지
  2. 혹은 여백을 넣긴 했는데 [여백을 넣고보니 이 탭이 아직 화면 영역에서 벗어나기 전인데 잘려보여요] 라며 이슈로 등록된다든지
  3. [그라디언트는 스크롤할 때만 보여야 하는데 처음부터 보이고 있어요] 라며 이슈로 등록된다든지

이런 애로사항들을 발견할 수 있어서 이런 점들을 해소할 수 있는 방안을 소개드리고자 합니다.


탭을 아래와 같이 구현해봅시다.

  1. 제목처럼 컨텐츠 수량이 많을 때는 좌측정렬, 적을 때는 중앙정렬이 되게
  2. 컨텐츠가 넘어가는 영역에는 그라디언트로 처리되게끔 (단, 스크롤 되기 전 처음 로딩시에 첫 번째 탭에 그라디언트가 겹치지 않도록 주의)
  3. 클릭시, 탭에 활성화 UI
  4. 활성화 된 탭의 Viewport도 기준 좌측으로 자연스럽게 이동
<template>
  <!-- role 속성은  접근성에 필요할 경우 넣는 속성이므로 swiper 필수값은 아닙니다 -->
  <swiper ref="filterSwiper" :options="swiperOption" role="tablist">
    <swiper-slide role="tab">111111</swiper-slide>
    <swiper-slide role="tab">222222</swiper-slide>
    <swiper-slide role="tab">333333</swiper-slide>
    <swiper-slide role="tab">444444</swiper-slide>
    <swiper-slide role="tab">555555</swiper-slide>
    <swiper-slide role="tab">666666</swiper-slide>
    <swiper-slide role="tab">777777</swiper-slide>
    <swiper-slide role="tab">888888</swiper-slide>
    <swiper-slide role="tab">999999</swiper-slide>
    <swiper-slide role="tab">101010101010</swiper-slide>
    <swiper-slide role="tab">111111111111</swiper-slide>
    <swiper-slide role="tab">121212121212</swiper-slide>
  </swiper>
</template>

<script>
import { swiper, swiperSlide } from 'vue-awesome-swiper'
import 'swiper/dist/css/swiper.min.css'

export default {
  name: 'FilterSwiper',
  data () {
    return {
      swiperOption: {
        slidesPerView: 'auto',
        spaceBetween: 6, // swiper-slide 사이의 간격 지정
        slidesOffsetBefore: 0, // slidesOffsetBefore는 첫번째 슬라이드의 시작점에 대한 변경할 때 사용
        slidesOffsetAfter: 0, // slidesOffsetAfter는 마지막 슬라이드 시작점 + 마지막 슬라이드 너비에 해당하는 위치의 변경이 필요할 때 사용
        freeMode: true, // freeMode를 사용시 스크롤하는 느낌으로 구현 가능
        centerInsufficientSlides: true, // 컨텐츠의 수량에 따라 중앙정렬 여부를 결정함
      }
    }
  },
  components: {
    swiper,
    swiperSlide
  }
}
</script>
<style lang="scss" scoped>
.swiper-container {
  .swiper-wrapper {
    .swiper-slide {
      width: auto; // auto 값을 지정해야 슬라이드의 width값이 텍스트 길이 기준으로 바뀜
      min-width: 56px; // min-width를 지정하지 않을 경우 텍스트가 1개 내지는 2개가 들어갈 때 탭 모양이 상이할 수 있으므로 넣어준다.
      padding: 0px 14px;
      font-size: 14px;
      line-height: 36px;
      text-align: center;
      color: #84868c;
      border: 0;
      border-radius: 18px;
      background: #f3f4f7;
      appearance: none;
      cursor: pointer;
    }
  }
}
</style>

만일 centerInsufficientSlides 파라미터만 알고 있다면, 중앙정렬은 어렵지 않습니다.

athena

탭 중앙정렬은 성공했지만, 탭 컨텐츠가 왼쪽부터 너무 여백 없이 붙어있는 것을 확인할 수 있습니다. 이것을 보완하기 위해 시작점 위치를 조정한 뒤에 gradient 효과를 넣습니다. (탭이 라이트그레이 계열이라 그라데이션이 잘 보이지 않을 수 있어 예시를 위해 탭 색상은 임의로 변경)

// 생략
<style lang="scss" scoped>
.swiper-container {
  padding: 0 20px; // 여백값을 지정할 경우 슬라이드의 시작점과 종점이 이에 영향을 받아 변경됨
  &:before,
  &:after { // 가상선택자를 활용하여 그라데이션 값 추가
    display: block;
    position: absolute;
    top: 0;
    width: 20px; // container에 준 여백값보다 크지 않게 사이즈 지정하기 (swiper-slide의 클릭 이벤트에 영향을 주지 않고, 이렇게 지정해야 그라데이션이 영역 내부에 있는 탭이 스크롤 하기 전엔 영향을 주지 않음)
    height: 100%;
    z-index: 10;
    content: "";
  }
  &:before {
    left: 0;
    background: linear-gradient(90deg, #fff -20.19%, rgba(255, 255, 255, 0.8) 18.31%, rgba(255, 255, 255, 0) 75%);
  }
  &:after {
    right: 0;
    background: linear-gradient(270deg, #fff -20.19%, rgba(255, 255, 255, 0.8) 18.31%, rgba(255, 255, 255, 0) 75%);
  }
  //...중략
}
</style>

swiper를 가장 외각에서 감싸고 있는 swiper-container 기준으로 여백을 추가하면, 해당 여백 기준으로 슬라이드의 시작점과 종점이 변경됩니다. 이것을 활용하여 가상 선택자에 그라데이션 효과를 넣어주면 자연스럽게 탭이 더 연장선상에 있다는 것을 인지할 수 있습니다.

athena
잘되는군요.


추가적으로 여백을 활용해서 슬라이드의 시작점과 종점을 변경하는 방법을 잘 사용할 경우, 아래와 같은 형태의 컨텐츠를 구현해야 할 때 쉽게 적용할 수 있습니다.

athena
centerInsufficientSlides 설정값만 빠지면 이렇게 container 영역에 잡힌 여백을 활용해서 슬라이드 시작점이 완전 중앙이거나 좌측이 아닌 슬라이드에 대해 구현할 수 있습니다.


이어서 클릭시 탭을 활성화 하는 기능을 추가해봅시다.

<template>
  <!--...위와 동일-->
</template>

<script>

export default {
  name: 'FilterSwiper',
  data () {
    // swiper 이벤트 메소드 내에서 this는 Swiper 객체를 대상으로 하기 때문에, Vue 인스턴스에 지정한 메소드 갖고오기 위해서는 별도로 지정해야함
    const _vm = this 
    return {
      swiperOption: {
        on {
          click: function () {
            _vm.activeTab()
          },
          // 모바일에서는 클릭 이벤트로 구현시 제대로 작동하지 않는 경향이 있어 tap을 함께 추가해주면 click만 추가할 때보다 훨씬 잘 동작합니다.
          tap: function () {
            _vm.activeTab()
          },
        }
      }
    }
  },
  methods: {
    swiperInit: function () {
      this.activeTab()
    }
   activeTab: function (swiper) {
     swiper = swiper || this.swiper
     // 그라데이션 영역이나 슬라이드 사이의 여백을 터치할 경우 이벤트가 발생하지 않도록 처리 (처음 로딩시에는 swiper내 params 설정이 clickedSlide 이름의 property 값이 없는 것을 이용하여 구분함)
     if (swiper.hasOwnProperty('clickedSlide') && !swiper.clickedSlide) return

      const slideSelector = `.${swiper.params.slideClass}` //swiper에 지정한 slide class명을 가져옴
      const selectedEl = swiper.clickedSlide || swiper.slides[swiper.params.initialSlide] // 초기 로딩시에는 옵션에서 initialSlide 값으로 지정한 슬라이드가 활성화 되도록 지정
      const swiperArr = document.querySelectorAll(slideSelector)
      Array.from(swiperArr).forEach((el) => {
        el.setAttribute('aria-selected', 'false')
        selectedEl.setAttribute('aria-selected', 'true')
      })
    }
  },
  computed: {
    swiper: function () {
      return this.$refs.filterSwiper.swiper
    },
  },
  mounted () {
    this.swiperInit()
  },
  components: {
    swiper,
    swiperSlide
  }
}
</script>
<style lang="scss" scoped>
.swiper-container {
  //...생략
  .swiper-wrapper {
    .swiper-slide {
      //...생략
      &[aria-selected="true"] {
        color: #fff;
        background: #000;
      }
    }
  }
}
</style>

실무에서는 click 이벤트를 button 태그에 활용했지만, swiper에 있는 click 이벤트를 활용할 경우 어떻게 보여주면 좋을지를 예시로 보여드리는 것도 괜찮을 것 같아 예시로 넣었습니다. 되도록 클릭 이벤트에는 button 태그나 a태그를 사용합시다.

Swiper에서는 기본적으로 활성화된 Slide에는 ‘swiper-slide-active’라는 class가 추가로 붙지만, 이 class는 클릭한 슬라이드가 아닌 Viewport 기준 가장 왼쪽에 위치하는 슬라이드에 붙는다는 특징이 있습니다.

그로 인해 다음과 같은 현상을 보이게 됩니다.
(알아보기 쉽도록 swiper-slide-active라는 class에 스타일을 추가했습니다)

  1. 스와이핑할 경우 자동으로 클릭과 상관없이 화면 가장 왼쪽에 보여지는 슬라이드에 swiper-slide-active class가 붙습니다.
athena

2. 마지막 슬라이드가 등장할 경우, 그 사이에 있는 슬라이드는 터치나 진행방향으로의 스와이프와 상관 없이 swiper-slide-active가 활성화 되지 않습니다.

athena

마지막 슬라이드가 종점에 도달한 상태에서는 아무리 누르고 진행방향을 더 당겨도 swiper-slide-active값이 더이상 넘어가지 않음.

따라서 swiper가 활성화 될 때 붙는 swiper-slide-active를 활용하는 것은 어렵다고 판단하여, 클릭시 aria-selected 라는 속성을 추가하여 구분값으로 대신 넣었습니다. (접근성을 고려하는 측면에서 적용한 속성이기 때문에 어려우신 분들은 class를 추가하는 classList.add를 활용하셔도 괜찮습니다.)

click한 탭이 활성화가 되고 있으니, 자연스럽게 활성화된 탭이 Viewport 기준 가장 왼쪽에 위치하는 기능을 추가해봅시다. (단, 맨 마지막 슬라이드와 같이 마지막 지점에 위치해야 하는 케이스의 탭들은 강제로 이동하지 않습니다.)

<template>
<!--...위와 동일-->
</template>
<script>
export default {
   data () {
     const _vm = this
     return {
      swiperOption: {
        //slideToClickedSlide: true, 제공하고 있지만 이거 안씀
        on: {
          click: function () {
            _vm.slideMoveTo()
            _vm.activeTab()
          },
          tap: function () {
            _vm.slideMoveTo()
            _vm.activeTab()
          }
        }
      }
    }
  },
  methods: {
    //...
    slideMoveTo: function (swiper = this.swiper) {
      // 그라데이션 영역이나 슬라이드 사이의 여백을 터치할 경우 이벤트가 발생하지 않도록 처리 
      // 슬라이드 이동의 초기값은 initialSlide에 영향을 받기 때문에 activeTab에 넣었던 hasOwnProperty 조건이 불필요
      if (!swiper.clickedSlide) return
      
      const activeIndex = swiper.clickedIndex
      swiper.slideTo(activeIndex)
    }
  },
  // 이하 동일
}
</script>

사실 Swiper에서는 슬라이드를 클릭시 해당 슬라이드를 이동시키는 slideToClickedSlide라는 파라미터를 제공하고 있습니다. 그럼에도 Swiper의 메소드인 slideTo를 활용해서 넣은 이유는 slideToClickedSlide에서 클릭을 했음에도 슬라이드가 되지 않는 버그가 특정한 조건에서 발생하기 때문입니다.

06
1. slideToClickedSlide 파라미터로 기능 구현을 했을 경우 경계선에 위치할 때 슬라이드 이동이 발생하지 않음 (6666 탭 주목)
07
2. slideTo 메소드로 구현할 경우, 애매한 경계선에 있더라도 슬라이드가 확실하게 이동함
athena

위의 조건에 맞춰 만들고 나니 Swiper는 기본적으로 스와이프 기능이 들어가 있는데 컨텐츠가 적은 경우에도 탭을 스치듯이 누를 경우 움찔움찔하며 동작하는 것이 뭔가 시각적으로 불편해졌습니다.

athena
컨텐츠는 이미 다 보여주고 있는데 뭔가 컨텐츠가 더 있을 거 같다며 스와이핑이 격렬하게 이뤄지는게 뭔가 불편합니다.


중앙에 위치할 경우에는 이것이 움직이지 않도록 하는 추가적인 작업이 필요해보입니다. Viewport 너비에 따라 스와이프가 되도록 옵션 및 설정을 추가해봅시다.

<template>
<!--...위와 동일-->
</template>
<script>
export default {
	//...
	data () {
      const _vm = this
      return {
        swiperOption: {
           //...
           on: {
             // ...
             resize: function () {
               this.allowTouchMove = !_vm.isOverview
             }
           }
         }
       }
    },
    computed: {
      swiper: function () {
        return this.$refs.filterSwiper.swiper
      },
      isOverview: function () { // swiper 전체 사이즈가 화면을 넘어가는지 여부 확인
        return window.innerWidth >= this.swiper.virtualSize
      }
    },
    methods: {
      swiperInit: function () {
        this.swiper.allowTouchMove = !this.isOverview // 초기 설정에 swiper 사이즈가 화면을 넘어가는지 여부 체크
        this.activeTab()
      }
	},
	mounted () {
     this.swiperInit()
    }
}
</script>
athena
athena

이제 화면 너비에 비해 탭의 수량이 적을 땐 격렬하게 클릭해도 흔들리지 않는 편안함을 느낄 수 있습니다.

<template>
  <swiper ref="filterSwiper" :options="swiperOption" role="tablist">
    <swiper-slide role="tab">111111</swiper-slide>
    <swiper-slide role="tab">222222</swiper-slide>
    <swiper-slide role="tab">333333</swiper-slide>
    <swiper-slide role="tab">444444</swiper-slide>
    <swiper-slide role="tab">555555</swiper-slide>
    <swiper-slide role="tab">666666</swiper-slide>
    <swiper-slide role="tab">777777</swiper-slide>
    <swiper-slide role="tab">888888</swiper-slide>
    <swiper-slide role="tab">999999</swiper-slide>
    <swiper-slide role="tab">101010101010</swiper-slide>
    <swiper-slide role="tab">111111111111</swiper-slide>
    <swiper-slide role="tab">121212121212</swiper-slide>
  </swiper>
</template>

<script>
import { swiper, swiperSlide } from 'vue-awesome-swiper'
import 'swiper/dist/css/swiper.min.css'

export default {
  name: 'FilterSwiper',
  data () {
    const _vm = this
    return {
      swiperOption: {
        slidesPerView: 'auto',
        spaceBetween: 6,
        slidesOffsetBefore: 0,
        slidesOffsetAfter: 0,
        freeMode: true,
        centerInsufficientSlides: true,
        on: {
          click: function () {
            _vm.slideMoveTo()	
            _vm.activeTab()						
          },
          tap: function () {
            _vm.slideMoveTo()	
            _vm.activeTab()
          },
          resize: function () {
            this.allowTouchMove = !_vm.isOverview
          }
        }
      }
    }
  },
  methods: {
    swiperInit: function () {
       this.swiper.allowTouchMove = !this.isOverview
       this.activeTab()
    },
    activeTab: function (swiper) {
      swiper = swiper || this.swiper
      if (swiper.hasOwnProperty('clickedSlide') && !swiper.clickedSlide) return

      const slideSelector = `.${swiper.params.slideClass}`
      const selectedEl = swiper.clickedSlide || swiper.slides[swiper.params.initialSlide]
      const swiperArr = document.querySelectorAll(slideSelector)
      Array.from(swiperArr).forEach((el) => {
        el.setAttribute('aria-selected', 'false')
        selectedEl.setAttribute('aria-selected', 'true')
      })
    },
    slideMoveTo: function (swiper = this.swiper) {
      if (!swiper.clickedSlide) return

      const activeIndex = swiper.clickedIndex
      swiper.slideTo(activeIndex)
    }
  },
  computed: {
    swiper: function () {
      return this.$refs.filterSwiper.swiper
    },
    isOverview: function () {
      return window.innerWidth >= this.swiper.virtualSize
    }
  },
  mounted () {
    this.swiperInit()
  },
  components: {
    swiper,
    swiperSlide
  }
}
</script>
<style lang="scss" scoped>
.swiper-container {
  padding: 0 20px;
  &:before,
  &:after {
    display: block;
    position: absolute;
    top: 0;
    width: 20px;
    height: 100%;
    z-index: 10;
    content: "";
  }
  &:before {
    left: 0;
    background: linear-gradient(90deg, #fff -20.19%, rgba(255, 255, 255, 0.8) 18.31%, rgba(255, 255, 255, 0) 75%);
  }
  &:after {
    right: 0;
    background: linear-gradient(270deg, #fff -20.19%, rgba(255, 255, 255, 0.8) 18.31%, rgba(255, 255, 255, 0) 75%);
  }
  .swiper-wrapper {
    .swiper-slide {
      width: auto;
      min-width: 56px;
      padding: 0px 14px;
      font-size: 14px;
      line-height: 36px;
      text-align: center;
      color: #84868c;
      border: 0;
      border-radius: 18px;
      background: #f3f4f7;
      appearance: none;
      cursor: pointer;
      &[aria-selected="true"] {
        color: #fff;
        background: #000;
      }
    }
  }
}
</style>

3-2. 화면 Viewport에 따라 서로 다른 effect를 가진 Swiper

💡
OO님, 모바일 목업 이미지를 Slide 기능으로 구현해주시면 좋겠는데요.
모바일에서는 Fade로, PC에서는 일반 Slide로 보여지게 해주세요!


모바일 목업 이미지의 경우,
특히 자연스러운 화면 전환을 선호하는 경향이 있어 이와 같은 요청이 굉장히 많은 편입니다.

athena
텍스트와 목업이 같이 슬라이드가 될 경우
athena
고정된 위치에서 텍스트와 이미지만 fade될 경우


좌 ← 우로 스와이핑 될 때마다 유저의 시선이 계속해서 움직여야 하는 슬라이드보다는, fade로 변화를 주는 것이 훨씬 더 안정적으로 노출된다는 사실을 알 수 있습니다. (UI/UX에 신경을 많이 쓰시는 분들이 세세하게 주문하는데는 다 이유가 있습니다.)

반면에 PC 화면은 보여줄 수 있는 화면 사이즈가 크기 때문에 컨텐츠를 나열해서 보여주는 것이 효과적이라 fade 보다는 기본 slide를 선호하는 편이죠.

athena

과거에 Swiper가 아닌 슬라이드로 작업할 때는 별도의 반응형 대응 옵션이 없어서 일일이 특정 Viewport에서 슬라이드 기능을 부순 뒤에 다시 재생성 해서 매번 부수고 만들고 만들고 부수고를 반복했었지만, Swiper에서는 반응형 옵션인 breakpoints를 제공하고 있기 때문에, 쉽게 적용할 줄 알았는데…. 문제가 생겼습니다.

breakpoints만 믿고 있었는데, 슬라이드가 정상적으로 노출되지 않는 겁니다.

athena

21년도 6월에 한 외국인이 React 환경에서 breakpoints 옵션을 활용하여 effect 변경이 가능한지 여부에 대해 문의한 글인데, 제작자는 이것이 불가능하다고 답변을 줬습니다.

athena

17년도에도 동일한 질문이 올라왔지만 제작자는 이 때도 생성된 슬라이더를 한번 부수고(destroy) 다시 재생성(reinit) 하는 방법을 안내해줬죠.

사실 이렇게 꾸준히 같은 기능에 대한 문의가 올라오면 기능을 넣어줄만도 할 텐데, 아무래도 슬라이드라는 것이 Fade는 레이어를 겹쳐서 투명도를 전환하여 하나씩 보여주고, Slide는 순차적으로 나열되어있다보니 공존시키는 것이 어려운가 봅니다.

안된다면 다른 길을 찾아야겠죠.

breakpoints를 비롯해서 동일한 이름의 컴포넌트를 v-if / v-else로도 구분해서 옵션을 넣어보기도 하고, watch로 화면변화를 감지해서 옵션을 다르게 찔러주기도 하면서 여러가지 시도를 해봤지만 if/else로 사용한 경우에는 제일 처음에 렌더링한 화면의 옵션값을 그대로 유지하고 있었고, watch로 사용한 경우에는 위에서 언급한 일반적인 슬라이드와 Fade를 활용한 슬라이드의 구조적인 차이 때문에 반응형에 맞춰 변화하지 않았습니다.

그러다가 Vuejs 공식 문서를 다시 한 번 정독 하던 중에 다른 컨텐츠에 대한 예시로 제시한 사례를 보고 응용해서 적용해본 결과, 완전히 breakpoints와 일치하는 형태는 아니더라도 약 90% 정도 만족스러운 결과를 얻어낼 수 있었습니다.

아래는 실제 서비스에 구현한 슬라이더의 일부를 커스텀 해서 작성한 내용입니다.
(실제 적용된 사례는 헬피 마이크로페이지 에서 보실 수 있습니다)

// 부모 컴포넌트
<template>
    <div>
      <keep-alive>
        <component v-if="isMobile" :is="'IntroSwiper'" :swiperOption="mobileOption"></component>
      </keep-alive>
      <keep-alive>
        <component v-if="!isMobile" :is="'IntroSwiper'" :swiperOption="pcOption"></component>
      </keep-alive>
	</div>
</template>
<script>
import IntroSwiper from '@/components/IntroSwiper'
export default {
  name: 'IntroPage',
  data() {
    return {
      isMobile: false,
      pcStandard: 769, // PC 화면의 Viewport 기준점
      mobileOption: { // 모바일 옵션 지정
        slidesPerView: 1,
        effect: 'fade',
        fadeEffect: {
          crossFade: true
        },
        loop: true,
      },
      pcOption: { // PC 옵션 지정
        slidesPerView: 3,
      }
    }
  },
  methods: {
    checkIsMobile: function () {
      this.isMobile = (window.innerWidth < this.pcStandard)
    },
    handleResize: function () {
      this.$nextTick(function () {
        this.checkIsMobile()
      })
    }
  },
  created () {
    this.checkIsMobile()
  },
  mounted () {
    window.addEventListener('resize', this.handleResize)
  },
  beforeDestroy () {
    window.removeEventListener('resize', this.handleResize)
  },
  components: {
    IntroSwiper
  }
}
</script>
// 자식 컴포넌트 (Swiper 영역)
<template>
  <swiper ref="introSwiper" :options="swiperOption">
    <swiper-slide>1</swiper-slide>
    <swiper-slide>2</swiper-slide>
    <swiper-slide>3</swiper-slide>
    <swiper-slide>4</swiper-slide>
    <swiper-slide>5</swiper-slide>
    <swiper-slide>6</swiper-slide>
    <swiper-slide>7</swiper-slide>
    <swiper-slide>8</swiper-slide>
    <swiper-slide>9</swiper-slide>
    <swiper-slide>10</swiper-slide>
    <swiper-slide>11</swiper-slide>
    <swiper-slide>12</swiper-slide>
  </swiper>
</template>

<script>
import { swiper, swiperSlide } from 'vue-awesome-swiper'
import 'swiper/dist/css/swiper.min.css'

export default {
  name: 'IntroSwiper',
  props: {
    swiperOption: {
      type: Object
    }
  },
  components: {
    swiper,
    swiperSlide
  }
}
</script>

<keep-alive>는 Vuejs 내에서 제공하는 내장 컴포넌트입니다.

<keep-alive>로 동적 컴포넌트를 감싸는 경우, 해당 컴포넌트가 비활성화가 되더라도 파괴하지 않고 캐시됩니다.

예를 들어 위와 같이 특정 Viewport에 대한 resize 이벤트를 설정했을 경우, 만일 내가 이미지 슬라이드가 100개인 슬라이더를 보던 중에 20번째의 이미지에서 769이상의 환경 변화를 경험하게 되더라도, 이 때 비활성화된 모바일 조건의 컴포넌트는 비활성 되기 직전에 보고 있던 20번째 이미지가 현재 보고 있던 이미지라는 상태값을 보존하게 되는 것이죠.


athena
athena


위의 이미지를 통해 뷰포트가 달라질 경우 다른 옵션 값이 적용된다는 점과, 비활성화 되기 전 마지막 값을 저장하는 것을 확인하실 수 있습니다.

물론, Swiper에서 제공하는 breakpoints로 분기할 때처럼 완전히 상태값을 공유하는 것이 아니기 때문에 뷰포트마다 보는 값이 달라진다(769미만의 화면에서 20번째 배너를 봤다고 해서 다른 화면에서 그 값을 받아오는 것은 아니다)는 점에서 완벽한 대안이 될 수는 없겠지만, 는 컴포넌트의 상태를 보존하고 재랜더링을 피하기 때문에 매번 destroy 후 재생성하는 방식보다 훨씬 효과적이라고 봅니다. 만일 Swiper에서 fade 외에도 breakpoints로 기능 변화를 줄 때 제작자가 어렵다라고 하는 것을 구현해야 할 기회가 있다면 한 번 고려해보시는 것도 좋을 것 같습니다.


Conclusion

Swiper가 지원하는 기능은 굉장히 다양하고, 일부만 잘 이해하고 활용해도 훨씬 더 사용자 친화적인 화면을 구현할 수 있습니다. 그동안 공식사이트에서 Vuejs 2.x 버전에 대해 지원하지 않고, 프로젝트가 IE 11까지 지원해서 사용해보는걸 망설였다면 한 번 사용해보시는 것을 추천드립니다.

이 글이 시행착오를 겪고 있을 분들에게 도움이 됐기를 바라며, 언제가 될지는 알 수 없지만 새롭고 재밌는 글로 다시 찾아뵙게 되기를 기원하겠습니다.


참고자료


최한솔 | 풀필먼트개발실 풀필먼트개발팀
브랜디, 오직 예쁜 옷만