Как мы :hover на iOS побеждали…

    Ни для кого, думаю, не секрет, что touch-устройства обрабатывают «мышиные» события несколько иначе, не так, как это происходит на десктоп-браузерах…

    Самый яркий для меня пример, это обработка псевдокласса :hover. Для начала iOS7, например, не будет реагировать на hover если только на элемент, или его родителя, не навешена обработка события click. Это хорошо видно вот на этом примере: jsfiddle.net/H8EmG — сколько не тыкай пальцем в текст — никаких подчеркиваний не увидишь. А в этом примере jsfiddle.net/H8EmG/1 «тычок» пальцем в текст будет приводить к его подчеркиванию. Интересный факт — пока не ткнем в другой элемент, текст так и будет сидеть под ховером…

    Другой интересный пример, это обработка появления элементов «по-наведению»: jsfiddle.net/ASRm9/1 Попробуйте нажать на текст. Сперва вы увидите текст «HOVER!», появившийся внутри строки, а вот второе нажатие уже вызовет alert('click'). Это происходит потому, что iOS понимает что за :hover что-то скрыто, и старается не сломать поведение, заложенное автором сайта.

    Но однажды мы столкнулись с такой багой, объяснить которую мы не смогли до сих пор, а на ее локализацию потребовался не один день отладки на iPad… Желающие подробностей, а также хитрого, как мне кажется, способа решения, наверное, всех проблем с :hover разом — прошу под кат…



    ВНЕЗАПНО, после очередного обновления сервиса, разработчиком «платформы» которого я являюсь, вскрылась неприятная проблема — на iPad нельзя выбрать ни одну строку практически во всех «таблицах», которые есть на сервисе. «Клик» просто не срабатывает! Надо заметить что «таблица» это не просто строчки и столбцы. В нашем случае это довольно «богатый» UI-элемент c отметками записей, сортировками, группировками, фильтрами, всякими «лесенками» выводом на печать и экспортом в PDF и Excel…

    После долгой и нудной локализации проблемы мы выделили изолированный, простой кусок HTML+CSS который давал схожий результат…

    • HTML-таблица, несколько строк, несколько столбцов
    • В одном из столбцов есть «чекбокс» — div который скрыт по-умолчанию и показывается при наведении на строку. Реализован через :hover
    • На строку навешен click
    • Таблица имеет размер больше, чем ее контейнер
    • Контейнер имеет фиксированный размер и у него включен overflow: scroll


    Вот пример: jsfiddle.net/822eG/4. Попробуйте понажимать по строкам таблицы. Hover будет срабатывать (вы увидите «чекбокс») а вот clickalert) вы не увидите как не старайтесь наживать на строчки.

    На эту тему я даже завел пост на SO stackoverflow.com/questions/21786375/ios-7-hover-click-issue-no-click-triggered-in-some-cases который не принес особого профита, кроме предложения включить (непонятно почему) -webkit-overflow-scrolling: touch на контейнере таблички который реально помогал на примере из jsFiddle, но не помогал на реальном приложении.

    В процессе обдумывания этого бардака пришло следующее решение (мой собственный ответ на вопрос на SO) — а что, если :hover заменить на CSS-класс, который «накидывать» кодом, отлавливая mouseenter/mouseleave? Этот простой фикс на самом деле все решает. Даже работать начинает «веселее» — не надо больше кликать два раза. От первого же нажатия получаем и alert и «чекбокс»: jsfiddle.net/822eG/10

    За неимением лучшего варианта стали обдумывать этот… На самом деле у нас очень большой code base. Много как «платформенного» кода, так и «прикладного», на этой платформе основанного. И кто его знает, кто, где и когда, при каких условиях захочет использовать :hover и захочет ли он при это он что-то скрыть или показать. В общем нужно чтобы было «все само (с)» а среднестатистический разработчик не думал о проблемах на iOS.

    В итоге получилось следующее решение:

    • С помощью MutationObserver (который есть в iOS 6-7) мониторим вставку тэгов link в head документа — мы это можем себе позволить, т.к. все стили у нас заведомо подключаются с помощью require.js и в Safari это гарантированно будет новый link
    • При добавлении новых link пробежимся по document.styleSheets и проанализируем их...
    • Переберем все правила и найдем среди них те, в селекторе которых присутствует :hover
    • Посмотрим на стили для таких селекторов, проверим нет ли там display отличного от none и visibility, отличного от visible
    • Если таковые найдутся — перепишем селектор, заменим :hover на .hover (т.е. псевдокласс на обычный класс)...
    • А на body навесим через delegate обработку mouseenter/mouseleave для найденного селектора, точнее для той его части, которая расположена до :hover


    К счастью сделать это оказалось совсем просто… Каждый styleSheet содержит коллекцию rules, в которой лежат собственно правила. Каждое правило обладает свойством selectorText которое можно менять на ходу. А также обладает коллекцией style где во-первых содержится набор свойств, заданных в данном стиле — они хранятся в виде «массива». У style есть .length, перебирая из по длине получим все свойства, измененные в данном стиле. Во-вторых в style содержатся значения измененных свойств. По индексу, равному имени свойства хранится значение свойства.

    То есть если у нас, скажем, есть CSS-код:

    .myClass:hover .block, .myItem:hover .element {
        color: red;
        display: block;
    }
    


    то у данного правила selectorText == '.myClass:hover .block, .myItem:hover .element', style.length == 2, style[0] == 'color', style[1] == 'display', style.color == 'red' а style.display == 'block'.

    Все остальное — дело техники…

    К сожалению выяснилось, что первичны обход правил работает (на наших объемах стилей и link-тэгов) не очень быстро… Профилирование показало, что обращение к rules занимает львиную долю времени. Возможно, WebKit инициализирует данное свойство лениво и первое обращение инициирует какой-то глубинный парсинг стилей в набор объектов.

    Вот что в итоге получилось:

    $(document).ready(function(){
    
       // константа, в которой мы определяем, под чем мы работаем
       if (!$ws._const.browser.isMobileSafari) {
          return;
       }
    
       var $body = $('body');
    
       // добавляем класс при наведении
       function addPseudoHover() {
          this.classList.add('ws-pseudo-hover');
       }
    
       // удаляем класс при уходе "мыши"
       function removePseudoHover() {
          this.classList.remove('ws-pseudo-hover');
       }
    
       // Используем в [].filter(...)
       function uniq(item, index, array) {
          return array.indexOf(item, index + 1) == -1;
       }
    
       function trimHoverBase(selector) {
          return selector.substr(0, selector.indexOf(':hover')).trim();
       }
    
       function filterHoverSelectors(selector) {
          return selector.indexOf(':hover') != -1;
       }
    
       function createBodyDelegate(hoverSelector){
          $body.delegate(hoverSelector, 'mouseenter', addPseudoHover);
          $body.delegate(hoverSelector, 'mouseleave', removePseudoHover);
       }
    
       function processMutationRecord(mutationRecord) {
          var needRefresh = false;
          if (mutationRecord.addedNodes) {
             for(var i = 0, l = mutationRecord.addedNodes.length; i < l; i++) {
                if (mutationRecord.addedNodes[i].nodeName == 'LINK') {
                   needRefresh = true;
                   break;
                }
             }
          }
          if (needRefresh) {
             checkStylesheetSetDebonuced(); // Не будем делать обработку слишком часто
          }
       }
    
       function checkStylesheetSet() {
          var
             allHoverSelectors = [],
             allRules = [],
             sheet, sheetCheckResult;
    
          for(var i = 0, l = document.styleSheets.length; i < l; i++) {
             sheet = document.styleSheets[i];
    
             // Проверим, что в стиле есть правила
             if (sheet.processed || sheet.rules.length === 0) {
                continue;
             }
             sheetCheckResult = checkCSSRuleSet(sheet);
             if (sheetCheckResult.rules.length > 0 && sheetCheckResult.selectors.length > 0) {
                Array.prototype.push.apply(allHoverSelectors, sheetCheckResult.selectors);
                Array.prototype.push.apply(allRules, sheetCheckResult.rules);
             }
             // чтобы не обрабатывать один и тот же блок несколько раз
             sheet.processed = true; 
          }
    
          // замена селектора
          allRules.forEach(function(aRule){
             aRule.selectorText = aRule.selectorText.replace(':hover', '.ws-pseudo-hover');
          });
    
          // фильтруем уникальные селекторы, сорздаем делегатов на body
          allHoverSelectors.map(trimHoverBase).filter(uniq).forEach(createBodyDelegate);
       }
    
       var checkStylesheetSetDebonuced = checkStylesheetSet.debounce(420);
    
       function checkCSSRuleSet(sheet) {
          var result = {
             selectors: [],
             rules: []
          };
          for(var i = 0, l = sheet.rules.length; i < l; i++) {
             var rule = sheet.rules[i];
    
             if (rule.styleSheet && rule.href /* instanceof CSSImportRule*/) {
                // Не забываем про @import
                checkCSSRuleSet(rule.styleSheet);
             } else if (rule.selectorText /* instanceof CSSStyleRule*/) {
                var hoverSelectors = getHoverSelectors(rule);
                if (hoverSelectors.length > 0) {
                   if (checkStyles(rule)) {
                      Array.prototype.push.apply(result.selectors, hoverSelectors);
                      result.rules.push(rule);
                   }
                }
             }
          }
          return result;
       }
    
       function checkStyles(rule) {
          for(var i = 0, l = rule.style.length; i < l; i++) {
             var styleItem = rule.style[i];
             if (styleItem == 'display' && rule.style.display !== 'none') {
                return true;
             }
    
             if (styleItem == 'visibility' && rule.style.visibility !== 'hidden') {
                return true;
             }
          }
          return false;
       }
    
       function getHoverSelectors(rule) {
          return rule.selectorText.split(',').filter(filterHoverSelectors);
       }
    
       // мониторим вставку новых детей в head
       new MutationObserver(function(mutationRecords){
          mutationRecords.forEach(processMutationRecord);
       }).observe(document.getElementsByTagName('head')[0], {
          childList: true
       });
    
    });
    


    Немного ссылок:
    Метки:
    Поделиться публикацией
    Похожие публикации
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 12
    • 0
      Буквально вчера разбирался почему не работает ховер для невидимого div с фиксированными размерами position: absolute. Добавил пустой onclick и ховер заработал :-)…

      Вообще с ховерами там всё плохо, в разные моменты времени он работает по разному, то пропадает если ткнуть мимо, то всегда установлен и никак не снять…
      • 0
        Так там в том и дело, что чтобы снять ховер нужно ткунть в другой элемент, который сможет его «обработать».
        • +2
          Не пробовали cursor:pointer как альтернативу onclick?
          • 0
            Не знал об этом. Но на самом деле в нашем контексте это не требуется, т.к. у нас нет задачи сделать ховер не навешивая клик. Задача именно в том, чтобы ховер и клик работали вместе т.к. клик у нас навешен всегда (такие таблицы).

            Но вариант интересный, спасибо.
        • 0
          Так там в том и дело, что чтобы снять ховер нужно ткунть в другой элемент, который сможет его «обработать».
          • +1
            не знаю зачем, но скажу, что МutationObserver с полифиллом работает в ie9, ie10, android 4.0, но не работает в android 2.3. Теперь вы тоже знаете эту бесполезную в данном контексте информацию.
            • 0
              Android 2.3? У кого-то целевая аудитория на этом? Пожалуйста, скажите, что это маловероятно. Мне даже страшно смотреть в статистику.
            • +2
              По моему мнению основная проблема не в самом :hover, а в том, что он не подходит для модели тач-устройств. Скорее нужно перерабатывать UI под тач-устройства идеологически. Приведу очень грубый пример: показываем некоторый вспомогательный элемент по hover. Тач-вариант: показываем его в дефолтном состоянии. Да, интерфейс станет более загруженным (разной степени загруженности, в зависимости от количества элементов с таким поведением), но и более понятным в тач-варианте: исчезнет целый промежуточный шаг с наведением. Безусловно, задача это нетривиальная и требует неслабой проектировки и продумывания.

              Кстати, аналогичным образом работают и всплывающие подсказки: на устройствах с курсором всплывающие подсказки весьма удобны, они не загромождают интерфейс (т.к. не видны постоянно), но дают моментальную контекстную помощь по практически любому элементу. На тач-девайсах это не работает. Я думаю, в результате появились гайды, которые появляются при первом старте приложения. Во многом они повторяют те самые старые добрые всплывающие подсказки, но отображаются все и сразу, одной серией, потому что после не будет возможности их показать.
              • +1
                Полностью согласен. Но в нашем случае есть постановка задачи, что система должна работать на iPad в web-варианте. Да, очевидно в некоторых местах интерфейс нужно делать иным. Но должна быть возможность им воспользоваться. Тут же просто принципиально невозможно было пользоваться системой, нужно было какое-то наименее интрузивное решение проблемы…
                • 0
                  Если у меня какой-то вспомогательный элемент показывается при :hover в десктоп-браузере, то с мобильных устройств — по клику. Требует определенное количество работ на проверку и правки макета, правда. Сильно лучше, чем сразу показывать этот вспомогательный элемент. Но, стоит отметить, что визуально пользователю должно быть понятно, что от него хотят.
                  • 0
                    В итоге такое решение и получено. Автоматически.
                • 0
                  Тут некто Лука Мозер утверждает, что ему удалось решить эту проблему простым aria-haspopup. Я попробовал и это, и aria-hasdropdown, но не работает с выпадающим меню, работающим на :hover.

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.