Маленькие детали для user experience важны не меньше больших. Вообще, из маленьких деталей как раз и строится впечатление от интерфейса. Одна из таких деталей — плавное пролистывание списков с помощью клавиатуры.

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

Ситуация кажется простой — по нажатию клавиш “вниз” и “вверх” выделение в списке меняется и загружается какой-то контент, связанный с пунктом. Например, список писем в почтовом ящике. Нажимаем “вниз”, получаем следующее письмо. Проблема в том, что при неправильной реализации (или по недосмотру), если загрузка контента занимает много времени, мы лишаем пользователя возможности быстро и плавно листать список: в случае, если контент загружается в том же треде, в котором происходит выделение в списке, загрузка контента блокирует выделение следующего пункта.

Если в Finder включить Quick Look и пролистать некэшированную папку со множеством “тяжелых” (для Quick Look) файлов, листание будет тормозить, пока превьюшки не будут кэшированы.

То же самое в iTunes, если включить показ обложки для выбранной песни.

Сравните скорость листания с выключенным показом обложки и с включенным. На скринкасте это менее заметно, чем на самом деле — при листании во втором случае такое чувство, что к клавишам добавили жесткости нажатия.

В Mail листание происходит по нажатию клавиши, а загрузка контента – по ее отпусканию. Если вы нажимаете клавишу со стрелкой, то пока ее держите, список листается. Отпускаете клавишу — выбранное письмо показывается. Таким образом, прокрутка всегда происходит быстро и плавно.

Это один из способов решения проблемы, и пожалуй, самый лучший, если его немного доработать: если выделение останавливается в начале списка или в конце, загрузка контента должна происходить сразу, а не по отпусканию клавиши. Иначе, пока вы держите кнопку, выделение будет стоять на одном пункте, а загружен будет другой.

Я попробовал реализовать такой способ, но так как я использую NSTableView с bindings, назначение выделенного пункта и отдача этого значения контроллеру происходит где-то глубоко внутри таблицы, поэтому задача оказалась непростой.

Но есть другой способ.

Реализация

Что если загружать контент не сразу, а через какое-то малюсенькое время после того, как выделение было сделано? Для этого добавляем в Run Loop таймер1, который будет вызывать метод загрузки. Если в то время, когда таймер сработает, выделение изменится, метод просто загрузит именно нынешний выделенный пункт.


  NSTimeInterval interval = 0.1;
  loadTimer = [NSTimer scheduledTimerWithTimeInterval:interval 
				target:self 
				selector:@selector(updateContent:) 
				userInfo:nil 				
				repeats:NO];

(Я так не форматирую код — это чтобы в блоге поместилось без горизонтального листания. Обычно я пишу все в одну строчку, а переносами занимается Xcode со включенным параметром “Wrap lines in editor”.)

Теперь листается чуть лучше, но все равно тормозит, потому что контент загружается через несколько пунктов. Тогда мы просто отсрочим обновление контента еще на чуть-чуть при вызове метода, в котором мы создаем таймер:


  NSTimeInterval interval = 0.1;
  if (!loadTimer)
	  loadTimer = [NSTimer scheduledTimerWithTimeInterval:interval
					target:self
					selector:@selector(updateContent:)
					userInfo:nil
					repeats:NO];
  else
	  [loadTimer setFireDate:
                  [NSDate dateWithTimeIntervalSinceNow:interval]];

Не забыв обнулить loadTimer в updateContent:


  - (void)updateContent:(NSTimer *)timer;
  {
	  // ... загрузить контент ...
	  loadTimer = nil;
  }

Теперь список листается плавно. Единственная проблема, хотя на глаз это и не очень заметно, при листании по нажатию и отпусканию клавиши, загрузка происходит не мгновенно, а через interval. Чтобы это обойти, можно у текущего события выяснить, не является ли оно повторным нажатием клавиши, и если да, то оставить interval со значением 0.1, а если нет, то сделать его нулевым (в таком случае, загрузка произойдет в следующей итерации Run Loop):


  NSTimeInterval interval = 0.0;
  NSEvent *currentEvent = [[NSApplication sharedApplication] currentEvent];
  if ([currentEvent type] == NSKeyDown && [currentEvent isARepeat])
  	  interval = 0.1;
	
  if (!loadTimer)
	  loadTimer = [NSTimer scheduledTimerWithTimeInterval:interval
					target:self
					selector:@selector(updateContent:)
					userInfo:nil
					repeats:NO];
  else
	  [loadTimer setFireDate:
                  [NSDate dateWithTimeIntervalSinceNow:interval]];

Назначение 0.0 интервалу, то есть, выполнение действия в следующей итерации Run Loop, на мой взгляд, даже лучше, чем если бы мы загружали контент моментально — таким образом новое выделение в таблице успевает отобразиться до того, как будет загружен контент (см. начало заметки).

Надо заметить, что интервал в 0.1, подобранный мной экпериментальным путем — это не универсальный ответ2, а компромисс между меньшим ожиданием загрузки контента при повторе клавиши и установленной скорости повтора клавиш. По умолчанию скорость повтора в Mac OS X стоит такая:

(У меня стоит максимальная, люблю быстро листать)

Значение в 0.1 работает для дефолтной скорости повтора и выше. Все что ниже и так медленно листает списки, поэтому, наверное, тут плавность не так важна.

P.S. Используйте accessor methods или properties для loadTimer чтобы избежать багов.

1 Напомню, что NSTimer — легкая штука. Для него не создается отдельный тред, таймер просто добавляется в Run Loop.

2 Универсальный ответ - 42 :)