Отучаем сенсорный экран смещать координаты прикосновений на пару мм вверх

    Думаю, мало кто замечал, что физические координаты прикосновения пальца и их программное отображение в iOS немного отличаются: iOS выдаёт точку, смещенную примерно на 1,5 мм вверх относительно реального прикосновения. Это сделано в интересах usability — точка, приближенная к ногтю, кажется более реалистичной, нежели лежащая ниже под подушечкой пальца. Кроме того, так лучше видно область экрана, куда нажимаешь.
    Чтобы было понятнее, о чем речь, можно скачать любую рисовалку (например Bamboo Paper, приложение не моё, бесплатное), заблокировать автоповорот экрана, нарисовать небольшую горизонтальную линию, затем перевернуть устройство вверх ногами (обязательно при блокировке автоповорота) и попытаться продолжить нарисованную линию. Скорее всего продолженная линия окажется ниже первоначальной.

    В большинстве приложений такое поведение незаметно, однако в некоторых может быть вредным. Например, в рисовалках, где отключен автоповорот и предполагается, что пользователь может крутить устройство как ему вздумается, необходимо обеспечить идентичную обработку прикосновений вне зависимости от текущего поворота устройства. Или в настольной игре, типа шахмат, где смещение координат может привести к тому, что игрок, играющий черными (и для которого интерфейс в портретном режиме расположен вверх ногами), при попытке подвинуть фигуру будет промахиваться и брать фигуру с клетки ниже.

    По моим тестам на iPad первого поколения сдвиг составил около 7 пикселей. Естественно, на других устройствах результат будет другим, необходимо тестировать.

    Открытого программного интерфейса для управления этим сдвигом я в документации Apple не нашел, а потому пришлось искать обходные пути.

    В случае рисовалок можно просто ввести поправку в логику методов touchesMoved и подобным:
    - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
    	//...
    	CGPoint point = [[touches anyObject] locationInView:self];
    	// Поправка имеет положительное значение, т.к. ось y направлена сверху вниз
    	point.y = point.y + 7;
    	//...
    }
    

    Однако это не всегда приемлемо, особенно при наличии некоторого объёма готового кода, который хочется повторно использовать без изменений.

    В качестве радикального решения в первой версии этой статьи я показал пример с внесением поправки в абсолютно все вызовы locationInView и previousLocationInView у экземпляров UITouch:
    #import "objc/runtime.h"
    @interface UITouch (Adjusted)
    -(CGPoint)adjustedLocationInView:(UIView *)view;
    -(CGPoint)adjustedPreviousLocationInView:(UIView *)view;
    @end
    @implementation UITouch (Adjusted)
    -(CGPoint)adjustedLocationInView:(UIView *)view{
        CGPoint point = [self adjustedLocationInView:view];
        point.y = point.y + 7;
        return point;
    }
    -(CGPoint)adjustedPreviousLocationInView:(UIView *)view{
        CGPoint point = [self adjustedPreviousLocationInView:view];
        point.y = point.y + 7;
        return point;
    }
    +(void)load{
        Class class = [UITouch class];
        Method locInViewMethod = class_getInstanceMethod(class, @selector(locationInView:));
        Method adjLocInViewMethod = class_getInstanceMethod(class, @selector(adjustedLocationInView:));
        method_exchangeImplementations(locInViewMethod, adjLocInViewMethod);
        Method prevLocInViewMethod = class_getInstanceMethod(class, @selector(previousLocationInView:));
        Method adjPrevLocInViewMethod = class_getInstanceMethod(class, @selector(adjustedPreviousLocationInView:));
        method_exchangeImplementations(prevLocInViewMethod, adjPrevLocInViewMethod); 
        NSLog(@"UITouch class is adjusted now.");
    }
    @end
    

    Здесь с помощью функции рантайма method_exchangeImplementations происходит обмен реализаций дефолтовых методов UITouch на наши с поправкой. Обратите внимание, файл с созданной категорией не обязательно импортировать в какой-либо другой .h или .m файл. Достаточно только добавить его в проект, а метод +load вызовется автоматически, т.к. сообщение +load автоматически посылается каждому классу и категории при добавлении оных в рантайм.
    Upd. Однако, как выяснилось в комментариях, начиная с iOS 5 Apple просит не использовать method_exchangeImplementations в приложениях. Спасибо wicharek за информацию. Поэтому для внесения поправки лучше использовать любой другой способ на свой вкус, например один из предложенных в комментариях.

    Итак, после введения поправки рисование на view будет работать идентично при любой ориентации устройства. Однако это не решит проблемы с шахматными фигурками: при определении, какому view послать событие прикосновения, по-прежнему будет использоваться точка со сдвигом, и мы можем попасть не в ту фигурку.
    Для определения того, какой view должен обработать прикосновение, используется метод hitTest:withEvent: у UIView, который работает следующим образом:
    • вызывается pointInside:withEvent: у self;
    • если рузультат NO, то hitTest:withEvent: возвращает nil, т.е. view на прикосновение не отвечает;
    • если результат YES, то метод рекурсивно посылает hitTest:withEvent: всем своим subview;
    • если один из subview вернул не-nil объект, то корневой hitTest:withEvent: возвращает этот объект;
    • если все subview вернули nil, либо view не имеет subview, то возвращается self;

    Таким образом, для корректного попадания прикосновений в фигурки шахмат, достаточно переопределить hitTest:withEvent у корневого view, содержащего все subview, для которых нам надо ввести поправку:
    @implementation ChessBoardView
    - (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
        point.y = point.y + 7;
        UIView *hitView = [super hitTest:point withEvent:event];
        return hitView;
    }
    @end
    

    После чего игроку, который играет черными, попадать в шахматные фигурки становится так же удобно, как и игроку, играющему белыми.

    Similar posts

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 28

      +7
      Костыль-драйвен девелопмент?
        +1
        Можно и так сказать. В отсутствие нужного public API от Apple приходится искать обходные пути. Если знаете путь прямее — было бы интересно почитать, собственно для того и статью создал. Я в тайне надеюсь, что кто-нибудь найдёт таки в закромах доках Apple нормальный API для отключения этого сдвига и поделится им в комментариях.
          +1
          Я, к сожалению. вам не советчик (что касается именно iPhone).

          Но, как программист, скажу что магическая цифра 7 на девайсах с другим ppi принесет тому, кто будет отлаживать, много спокойных и приятных минут работы.

          Вы же знаете, что величина «примерно 3мм», данные о ppi известны. en.wikipedia.org/wiki/List_of_displays_by_pixel_density#Apple_Inc

          Забиваете данные о ppi в карту, при определении девайса, находите это смещение.

          Дальше delta = 3 / 25.4 * ppi = 0.118110236 * 132 (для ipad) = 15.59 пикселей.

          Однако. В рассчетах фейл.

          Но это вполне может говорить, что фейл в исходных данных. Значит, это не 3мм, а меньше. А вдруг это смещение адаптивно? Значит, надо пытаться достучаться до apple и выяснить, каково же это смещение.

          Я не удивлюсь, что в итоге вы будете первооткрывателем недокументированной функции :)

            0
            Хех, а ведь Вы правы. 3 мм это разница между линиями в опыте с Bamboo Paper, т.е. в миллиметрах реальное смещение 1.5, и delta примерно сходится. Ошибся, спасибо.
            По поводу магической цифры — специально указал, что это цифра только для конкретного устройства (iPad 1), и нужно дополнительно тестировать на остальных.
              0
              Уточню про миллиметры для ясности. Именно на 3 миллиметра будет промахиваться игрок, привыкший к стандартному смещению, если устройство перевернуть (например, для игры черными в шахматах).
            0
            Никого не слушайте!!! Свежая сочная фрагментация платформы своими руками в своем же приложении — это же прекрасно!
            (ага, когда будут выходить новый яблокодевайсы с другими ppi вам придется добавлять очередные константы и выпускать новую версию)
              0
              Вы предлагаете поступить как большинство разработчиков, и никак не учитывать этот сдвиг в приложении? Чтобы пользователь тянул фишку, а она не тянулась/тянулась другая? Нет, я за то, чтобы для известных устройств ввести смещение и сделать интерфейс более удобным для пользователя, пусть это и несёт некоторые накладные расходы для программиста.
          +3
          А вот на «ну погоди» такого не было!
            +7
            Зачем залезать на уровень рантаймовой магии, если можно просто обойтись категорией, в которой вы добавите adjustedLocationInView и adjustedPreviousLocationInView? Переправка абсолютно всех вызовов locationInView во всем проекте — очень плохое решение. Эти методы зовутся не так часто, и если вам уж так надо поправить положение UITouch, то лучше потратить 5-10 минут и заменить вызов оригинального метода на свой собственный, там где это надо.
              0
              Хороший вопрос. Ответ — для того, чтобы использовать ранее написанные и отлаженные классы и не залезать в их реализацию. Тут надо выбирать: либо мы перелопачиваем весь существующий код и вносим поправки, либо аккуратно в одном месте включаем/отключаем сдвиг. Это во-первых. А во-вторых, я показал всего лишь подход, в реальном проекте сдвиг в UITouch будет включаться/выключаться по требованию, например так:
              @interface AdjustedPaintingView:PaintingView
              @end
              @implementation AdjustedPaintingView
              - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
              	[UITouch enableAdjustmentsForLocations];
              	[super touchesBegan:touches withEvent:event]; 
              }
              - (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
              	[UITouch disableAdjustmentsForLocations];
              	[super touchesEnded:touches withEvent:event]; 
              }
              // ...
              @end
              

              Где PaintingView — повторно используемый класс, в реализацию которого лезть нежелательно.
              Но это уже детали реализации, которые зависят от конкретного приложения, я не хотел заострять на них внимание в статье.

                0
                С трудом могу представить ситауцию, в которой locationInView будет вызываться в настолько большом количестве классов, что код предеться «перелопачивать». runtime.h позволяет делать много интересных вещей, но если есть другое решение — лучше воспользоватся им. Если уж правка кода PaintingView совсем невозможна, в вашем случае лучше бует воспользоватся декоратором: завернуть UITouch в NSProxy, форвардить вызовы всех методов к оргинальному объекту, в locationInView и previousLocationInView вносить поправки.
                  0
                  Соглашусь, решение с NSProxy должно быть лучше.
                +1
                Меня всегда удивляло почему сишники пользуют GL_UINT (WORD16 или подобное), нежели short unsigned int.
                Но однажды я понял — класс/тип unsigned short int имеет фатальный недостаток
                  +1
                  На самом деле в GL_UINT, QINT16, WORD16 и т.д. есть смысл — у них размер вегда будет одинаков, независимо от архитектуры процессора, чего нельзя сказать о short int. Все эти библиотеки, как правило, начинали писатся до 1999 года, когда в стандарте появился stdint.h с его int8_t, int16_t, int32_t и т.п.
                    0
                    Да, я знаю. Сам сишник. Действительно, ширина int варьируется.

                    Но ширина short и long постоянная и кросс-платформенная.
                      0
                      Станарт, даже последний, ширину типов не прописывает. Там только сказано, что short
                        0
                        Парсер лох.
                        В стандарте написано, что short <= int <= long <=long long
                        Так что кросплатформенность short и long скорее счастливая случайность, чем закономоерность и расчитывать на нее я бы не стал.
                0
                Ну надо же. А есть пруф про 3мм? Потому что, судя по вот этому видео — никакого сдвига нет (смотреть с 1:55)
                  0
                  Я думал записать видео, да времени жалко, если честно.
                  Но вот кадр из видео, что Вы привели:

                  Обратите внимание, что верхняя горизонтальная линия проходит ближе к краю экрана, чем нижняя. Очень похоже, что это из-за обсуждаемого сдвига. По той же причине в большинстве рисовалок при попытке тонкой линией закрасить весь экран в нижней части остаётся тонкая незакрашенная полоска.
                    +1
                    А это не потому что манипулятор робота так установлен? Он же не может в точности повторять очертания экрана. Вы присмотритесь к моментам видео, где крупным планом показано, как он рисует — линии образуются точно в центре соприкосновения манипулятора с экраном.
                      0
                      Я не могу утверждать, по какой причине разница в расстояниях в этом видео.
                      Но неужели Вы думаете, что в этой статье я решаю несуществующую проблему? :)
                      Если есть устройство — установите Bamboo Paper и проведите тест из начала статьи.
                      Не буду обещать, но если будет время, сниму видео замера сдвига в 7 пикселей.
                        0
                        Проверил на Adobe Ideas.
                        Нужно выбирать самые тонкие линии и самый мелкий масштаб, тогда описанный вами эффект виден. Если линии толщиной в два миллиметра, то они рисуются точно там, где палец касается экрана. На тонких линиях нельзя закрасить нижние пару миллиметров экрана.
                          0
                          Нет, я не утверждаю, что вы решаете несуществующую проблему. Но, возможно, это проблема конкретного приложения или она [проблема] вытекает из каких-то других предпосылок. К сожалению, у меня под рукой нет iPad, чтобы проверить наличие проблемы. Попробую на iPhone 4.
                            0
                            Тут дело в том, что это скорее особенность, которая выливается в проблему лишь в определенных типах приложений, типа шахмат или эрудита. Только что проверил в Scrabble — пытаешься потянуть букву за нижний край, а попадаешь на букву ниже, при этом в привычной ориентации всё нормально.
                            В тех же рисовалках можно поступить хитрее: разрешить автоповорот, но вращать только меню, т.е. вращение интерфейса компенсировать вращением холста в обратную сторону, создавая ощущение его (холста) неподвижности. Так сделано в том же Adobe Ideas и например в моём LiveSketch HD. При таком подходе направление сдвига будет меняться вместе с поворотом устройства и пользователь сдвиг не заметит.
                    0
                    Использовать method_exchangeImplementations нежелательно, хоть и весело :) В iOS 5 с ним проблемы и Apple просит не использовать эту фичу:
                    stackoverflow.com/questions/7720947/method-swizzling-in-ios-5
                      0
                      А вот это очень ценная информация. Честно говоря жаль, т.к. на использовании этой функции завязаны многие интересные библиотеки, например по добавлению АОП в Objective-C.
                      Не знаете, Apple какую-нибудь альтернативу взамен предоставил?
                        0
                        Вообще это они здорово придумали. На WWDC 2010 в одном из видео этот трюк разрекламировали, а в 2011 в iOS 5 решили запретить. :(
                        0
                        На самом деле смещение координат уже давно было заметно. Особенно это заметно на людях, которые долгое время пользовались android устройствами, и им дают в руки iphone — в кнопку 30 на 30 px с первого раза люди обычно не попадают.
                        Также всегда была заметно, если у тебя на statusBar висит стандартный обработчик scrollView — topToScroll = YES; прокручивать он начинает уже с нажатия на navigationBar.
                        Кстати рабочая область кнопок на navigationBar — тоже значительно расширена — и по высоте всегда является высотой navigationBar, а по ширине значительно больше, чем истинный размер кнопки.

                        Only users with full accounts can post comments. Log in, please.