apps_grid_controller.mm revision f2477e01787aa58f445919b809d89e252beef54f
1// Copyright 2013 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5#import "ui/app_list/cocoa/apps_grid_controller.h"
6
7#include "base/mac/foundation_util.h"
8#include "ui/app_list/app_list_item_model.h"
9#include "ui/app_list/app_list_model.h"
10#include "ui/app_list/app_list_model_observer.h"
11#include "ui/app_list/app_list_view_delegate.h"
12#import "ui/app_list/cocoa/apps_collection_view_drag_manager.h"
13#import "ui/app_list/cocoa/apps_grid_view_item.h"
14#import "ui/app_list/cocoa/apps_pagination_model_observer.h"
15#include "ui/base/models/list_model_observer.h"
16
17namespace {
18
19// OSX app list has hardcoded rows and columns for now.
20const int kFixedRows = 4;
21const int kFixedColumns = 4;
22const int kItemsPerPage = kFixedRows * kFixedColumns;
23
24// Padding space in pixels for fixed layout.
25const CGFloat kGridTopPadding = 1;
26const CGFloat kLeftRightPadding = 21;
27const CGFloat kScrollerPadding = 16;
28
29// Preferred tile size when showing in fixed layout. These should be even
30// numbers to ensure that if they are grown 50% they remain integers.
31const CGFloat kPreferredTileWidth = 88;
32const CGFloat kPreferredTileHeight = 98;
33
34const CGFloat kViewWidth =
35    kFixedColumns * kPreferredTileWidth + 2 * kLeftRightPadding;
36const CGFloat kViewHeight = kFixedRows * kPreferredTileHeight;
37
38const NSTimeInterval kScrollWhileDraggingDelay = 1.0;
39NSTimeInterval g_scroll_duration = 0.18;
40
41}  // namespace
42
43@interface AppsGridController ()
44
45- (void)scrollToPageWithTimer:(size_t)targetPage;
46- (void)onTimer:(NSTimer*)theTimer;
47
48// Cancel a currently running scroll animation.
49- (void)cancelScrollAnimation;
50
51// Index of the page with the most content currently visible.
52- (size_t)nearestPageIndex;
53
54// Bootstrap the views this class controls.
55- (void)loadAndSetView;
56
57- (void)boundsDidChange:(NSNotification*)notification;
58
59// Action for buttons in the grid.
60- (void)onItemClicked:(id)sender;
61
62- (AppsGridViewItem*)itemAtPageIndex:(size_t)pageIndex
63                         indexInPage:(size_t)indexInPage;
64
65// Return the button of the selected item.
66- (NSButton*)selectedButton;
67
68// The scroll view holding the grid pages.
69- (NSScrollView*)gridScrollView;
70
71- (NSView*)pagesContainerView;
72
73// Create any new pages after updating |items_|.
74- (void)updatePages:(size_t)startItemIndex;
75
76- (void)updatePageContent:(size_t)pageIndex
77               resetModel:(BOOL)resetModel;
78
79// Bridged methods for AppListItemListObserver.
80- (void)listItemAdded:(size_t)index
81                 item:(app_list::AppListItemModel*)item;
82
83- (void)listItemRemoved:(size_t)index;
84
85- (void)listItemMovedFromIndex:(size_t)fromIndex
86                  toModelIndex:(size_t)toIndex;
87
88// Moves the selection by |indexDelta| items.
89- (BOOL)moveSelectionByDelta:(int)indexDelta;
90
91@end
92
93namespace app_list {
94
95class AppsGridDelegateBridge : public AppListItemListObserver {
96 public:
97  AppsGridDelegateBridge(AppsGridController* parent) : parent_(parent) {}
98
99 private:
100  // Overridden from AppListItemListObserver:
101  virtual void OnListItemAdded(size_t index, AppListItemModel* item) OVERRIDE {
102    [parent_ listItemAdded:index
103                      item:item];
104  }
105  virtual void OnListItemRemoved(size_t index,
106                                 AppListItemModel* item) OVERRIDE {
107    [parent_ listItemRemoved:index];
108  }
109  virtual void OnListItemMoved(size_t from_index,
110                               size_t to_index,
111                               AppListItemModel* item) OVERRIDE {
112    [parent_ listItemMovedFromIndex:from_index
113                       toModelIndex:to_index];
114  }
115
116  AppsGridController* parent_;  // Weak, owns us.
117
118  DISALLOW_COPY_AND_ASSIGN(AppsGridDelegateBridge);
119};
120
121}  // namespace app_list
122
123@interface PageContainerView : NSView;
124@end
125
126// The container view needs to flip coordinates so that it is laid out
127// correctly whether or not there is a horizontal scrollbar.
128@implementation PageContainerView
129
130- (BOOL)isFlipped {
131  return YES;
132}
133
134@end
135
136@implementation AppsGridController
137
138+ (void)setScrollAnimationDuration:(NSTimeInterval)duration {
139  g_scroll_duration = duration;
140}
141
142+ (CGFloat)scrollerPadding {
143  return kScrollerPadding;
144}
145
146@synthesize paginationObserver = paginationObserver_;
147
148- (id)init {
149  if ((self = [super init])) {
150    bridge_.reset(new app_list::AppsGridDelegateBridge(self));
151    NSSize cellSize = NSMakeSize(kPreferredTileWidth, kPreferredTileHeight);
152    dragManager_.reset(
153        [[AppsCollectionViewDragManager alloc] initWithCellSize:cellSize
154                                                           rows:kFixedRows
155                                                        columns:kFixedColumns
156                                                 gridController:self]);
157    pages_.reset([[NSMutableArray alloc] init]);
158    items_.reset([[NSMutableArray alloc] init]);
159    [self loadAndSetView];
160    [self updatePages:0];
161  }
162  return self;
163}
164
165- (void)dealloc {
166  [[NSNotificationCenter defaultCenter] removeObserver:self];
167  [super dealloc];
168}
169
170- (NSCollectionView*)collectionViewAtPageIndex:(size_t)pageIndex {
171  return [pages_ objectAtIndex:pageIndex];
172}
173
174- (size_t)pageIndexForCollectionView:(NSCollectionView*)page {
175  for (size_t pageIndex = 0; pageIndex < [pages_ count]; ++pageIndex) {
176    if (page == [self collectionViewAtPageIndex:pageIndex])
177      return pageIndex;
178  }
179  return NSNotFound;
180}
181
182- (app_list::AppListModel*)model {
183  return delegate_ ? delegate_->GetModel() : NULL;
184}
185
186- (void)setDelegate:(app_list::AppListViewDelegate*)newDelegate {
187  if (delegate_) {
188    app_list::AppListModel* oldModel = delegate_->GetModel();
189    if (oldModel)
190      oldModel->item_list()->RemoveObserver(bridge_.get());
191  }
192
193  // Since the old model may be getting deleted, and the AppKit objects might
194  // be sitting in an NSAutoreleasePool, ensure there are no references to
195  // the model.
196  for (size_t i = 0; i < [items_ count]; ++i)
197    [[self itemAtIndex:i] setModel:NULL];
198
199  [items_ removeAllObjects];
200  [self updatePages:0];
201  [self scrollToPage:0];
202
203  delegate_ = newDelegate;
204  if (!delegate_)
205    return;
206
207  app_list::AppListModel* newModel = delegate_->GetModel();
208  if (!newModel)
209    return;
210
211  newModel->item_list()->AddObserver(bridge_.get());
212  for (size_t i = 0; i < newModel->item_list()->item_count(); ++i) {
213    app_list::AppListItemModel* itemModel = newModel->item_list()->item_at(i);
214    [items_ insertObject:[NSValue valueWithPointer:itemModel]
215                 atIndex:i];
216  }
217  [self updatePages:0];
218}
219
220- (size_t)visiblePage {
221  return visiblePage_;
222}
223
224- (void)activateSelection {
225  [[self selectedButton] performClick:self];
226}
227
228- (size_t)pageCount {
229  return [pages_ count];
230}
231
232- (size_t)itemCount {
233  return [items_ count];
234}
235
236- (void)scrollToPage:(size_t)pageIndex {
237  NSClipView* clipView = [[self gridScrollView] contentView];
238  NSPoint newOrigin = [clipView bounds].origin;
239
240  // Scrolling outside of this range is edge elasticity, which animates
241  // automatically.
242  if ((pageIndex == 0 && (newOrigin.x <= 0)) ||
243      (pageIndex + 1 == [self pageCount] &&
244          newOrigin.x >= pageIndex * kViewWidth)) {
245    return;
246  }
247
248  // Clear any selection on the current page (unless it has been removed).
249  if (visiblePage_ < [pages_ count]) {
250    [[self collectionViewAtPageIndex:visiblePage_]
251        setSelectionIndexes:[NSIndexSet indexSet]];
252  }
253
254  newOrigin.x = pageIndex * kViewWidth;
255  [NSAnimationContext beginGrouping];
256  [[NSAnimationContext currentContext] setDuration:g_scroll_duration];
257  [[clipView animator] setBoundsOrigin:newOrigin];
258  [NSAnimationContext endGrouping];
259  animatingScroll_ = YES;
260  targetScrollPage_ = pageIndex;
261  [self cancelScrollTimer];
262}
263
264- (void)maybeChangePageForPoint:(NSPoint)locationInWindow {
265  NSPoint pointInView = [[self view] convertPoint:locationInWindow
266                                         fromView:nil];
267  // Check if the point is outside the view on the left or right.
268  if (pointInView.x <= 0 || pointInView.x >= NSWidth([[self view] bounds])) {
269    size_t targetPage = visiblePage_;
270    if (pointInView.x <= 0)
271      targetPage -= targetPage != 0 ? 1 : 0;
272    else
273      targetPage += targetPage < [pages_ count] - 1 ? 1 : 0;
274    [self scrollToPageWithTimer:targetPage];
275    return;
276  }
277
278  if (paginationObserver_) {
279    NSInteger segment =
280        [paginationObserver_ pagerSegmentAtLocation:locationInWindow];
281    if (segment >= 0 && static_cast<size_t>(segment) != targetScrollPage_) {
282      [self scrollToPageWithTimer:segment];
283      return;
284    }
285  }
286
287  // Otherwise the point may have moved back into the view.
288  [self cancelScrollTimer];
289}
290
291- (void)cancelScrollTimer {
292  scheduledScrollPage_ = targetScrollPage_;
293  [scrollWhileDraggingTimer_ invalidate];
294}
295
296- (void)scrollToPageWithTimer:(size_t)targetPage {
297  if (targetPage == targetScrollPage_) {
298    [self cancelScrollTimer];
299    return;
300  }
301
302  if (targetPage == scheduledScrollPage_)
303    return;
304
305  scheduledScrollPage_ = targetPage;
306  [scrollWhileDraggingTimer_ invalidate];
307  scrollWhileDraggingTimer_.reset(
308      [[NSTimer scheduledTimerWithTimeInterval:kScrollWhileDraggingDelay
309                                        target:self
310                                      selector:@selector(onTimer:)
311                                      userInfo:nil
312                                       repeats:NO] retain]);
313}
314
315- (void)onTimer:(NSTimer*)theTimer {
316  if (scheduledScrollPage_ == targetScrollPage_)
317    return;  // Already animating scroll.
318
319  [self scrollToPage:scheduledScrollPage_];
320}
321
322- (void)cancelScrollAnimation {
323  NSClipView* clipView = [[self gridScrollView] contentView];
324  [NSAnimationContext beginGrouping];
325  [[NSAnimationContext currentContext] setDuration:0];
326  [[clipView animator] setBoundsOrigin:[clipView bounds].origin];
327  [NSAnimationContext endGrouping];
328  animatingScroll_ = NO;
329}
330
331- (size_t)nearestPageIndex {
332  return lround(
333      NSMinX([[[self gridScrollView] contentView] bounds]) / kViewWidth);
334}
335
336- (void)userScrolling:(BOOL)isScrolling {
337  if (isScrolling) {
338    if (animatingScroll_)
339      [self cancelScrollAnimation];
340  } else {
341    [self scrollToPage:[self nearestPageIndex]];
342  }
343}
344
345- (void)loadAndSetView {
346  base::scoped_nsobject<PageContainerView> pagesContainer(
347      [[PageContainerView alloc] initWithFrame:NSZeroRect]);
348
349  NSRect scrollFrame = NSMakeRect(0, kGridTopPadding, kViewWidth,
350                                  kViewHeight + kScrollerPadding);
351  base::scoped_nsobject<ScrollViewWithNoScrollbars> scrollView(
352      [[ScrollViewWithNoScrollbars alloc] initWithFrame:scrollFrame]);
353  [scrollView setBorderType:NSNoBorder];
354  [scrollView setLineScroll:kViewWidth];
355  [scrollView setPageScroll:kViewWidth];
356  [scrollView setDelegate:self];
357  [scrollView setDocumentView:pagesContainer];
358  [scrollView setDrawsBackground:NO];
359
360  [[NSNotificationCenter defaultCenter]
361      addObserver:self
362         selector:@selector(boundsDidChange:)
363             name:NSViewBoundsDidChangeNotification
364           object:[scrollView contentView]];
365
366  [self setView:scrollView];
367}
368
369- (void)boundsDidChange:(NSNotification*)notification {
370  size_t newPage = [self nearestPageIndex];
371  if (newPage == visiblePage_) {
372    [paginationObserver_ pageVisibilityChanged];
373    return;
374  }
375
376  visiblePage_ = newPage;
377  [paginationObserver_ selectedPageChanged:newPage];
378  [paginationObserver_ pageVisibilityChanged];
379}
380
381- (void)onItemClicked:(id)sender {
382  for (size_t i = 0; i < [items_ count]; ++i) {
383    AppsGridViewItem* item = [self itemAtIndex:i];
384    if ([[item button] isEqual:sender])
385      [item model]->Activate(0);
386  }
387}
388
389- (AppsGridViewItem*)itemAtPageIndex:(size_t)pageIndex
390                         indexInPage:(size_t)indexInPage {
391  return base::mac::ObjCCastStrict<AppsGridViewItem>(
392      [[self collectionViewAtPageIndex:pageIndex] itemAtIndex:indexInPage]);
393}
394
395- (AppsGridViewItem*)itemAtIndex:(size_t)itemIndex {
396  const size_t pageIndex = itemIndex / kItemsPerPage;
397  return [self itemAtPageIndex:pageIndex
398                   indexInPage:itemIndex - pageIndex * kItemsPerPage];
399}
400
401- (NSUInteger)selectedItemIndex {
402  NSCollectionView* page = [self collectionViewAtPageIndex:visiblePage_];
403  NSUInteger indexOnPage = [[page selectionIndexes] firstIndex];
404  if (indexOnPage == NSNotFound)
405    return NSNotFound;
406
407  return indexOnPage + visiblePage_ * kItemsPerPage;
408}
409
410- (NSButton*)selectedButton {
411  NSUInteger index = [self selectedItemIndex];
412  if (index == NSNotFound)
413    return nil;
414
415  return [[self itemAtIndex:index] button];
416}
417
418- (NSScrollView*)gridScrollView {
419  return base::mac::ObjCCastStrict<NSScrollView>([self view]);
420}
421
422- (NSView*)pagesContainerView {
423  return [[self gridScrollView] documentView];
424}
425
426- (void)updatePages:(size_t)startItemIndex {
427  // Note there is always at least one page.
428  size_t targetPages = 1;
429  if ([items_ count] != 0)
430    targetPages = ([items_ count] - 1) / kItemsPerPage + 1;
431
432  const size_t currentPages = [self pageCount];
433  // First see if the number of pages have changed.
434  if (targetPages != currentPages) {
435    if (targetPages < currentPages) {
436      // Pages need to be removed.
437      [pages_ removeObjectsInRange:NSMakeRange(targetPages,
438                                               currentPages - targetPages)];
439    } else {
440      // Pages need to be added.
441      for (size_t i = currentPages; i < targetPages; ++i) {
442        NSRect pageFrame = NSMakeRect(
443            kLeftRightPadding + kViewWidth * i, 0,
444            kViewWidth, kViewHeight);
445        [pages_ addObject:[dragManager_ makePageWithFrame:pageFrame]];
446      }
447    }
448
449    [[self pagesContainerView] setSubviews:pages_];
450    NSSize pagesSize = NSMakeSize(kViewWidth * targetPages, kViewHeight);
451    [[self pagesContainerView] setFrameSize:pagesSize];
452    [paginationObserver_ totalPagesChanged];
453  }
454
455  const size_t startPage = startItemIndex / kItemsPerPage;
456  // All pages on or after |startPage| may need items added or removed.
457  for (size_t pageIndex = startPage; pageIndex < targetPages; ++pageIndex) {
458    [self updatePageContent:pageIndex
459                 resetModel:YES];
460  }
461}
462
463- (void)updatePageContent:(size_t)pageIndex
464               resetModel:(BOOL)resetModel {
465  NSCollectionView* pageView = [self collectionViewAtPageIndex:pageIndex];
466  if (resetModel) {
467    // Clear the models first, otherwise removed items could be autoreleased at
468    // an unknown point in the future, when the model owner may have gone away.
469    for (size_t i = 0; i < [[pageView content] count]; ++i) {
470      AppsGridViewItem* item = base::mac::ObjCCastStrict<AppsGridViewItem>(
471          [pageView itemAtIndex:i]);
472      [item setModel:NULL];
473    }
474  }
475
476  NSRange inPageRange = NSIntersectionRange(
477      NSMakeRange(pageIndex * kItemsPerPage, kItemsPerPage),
478      NSMakeRange(0, [items_ count]));
479  NSArray* pageContent = [items_ subarrayWithRange:inPageRange];
480  [pageView setContent:pageContent];
481  if (!resetModel)
482    return;
483
484  for (size_t i = 0; i < [pageContent count]; ++i) {
485    AppsGridViewItem* item = base::mac::ObjCCastStrict<AppsGridViewItem>(
486        [pageView itemAtIndex:i]);
487    [item setModel:static_cast<app_list::AppListItemModel*>(
488        [[pageContent objectAtIndex:i] pointerValue])];
489  }
490}
491
492- (void)moveItemInView:(size_t)fromIndex
493           toItemIndex:(size_t)toIndex {
494  base::scoped_nsobject<NSValue> item(
495      [[items_ objectAtIndex:fromIndex] retain]);
496  [items_ removeObjectAtIndex:fromIndex];
497  [items_ insertObject:item
498               atIndex:toIndex];
499
500  size_t fromPageIndex = fromIndex / kItemsPerPage;
501  size_t toPageIndex = toIndex / kItemsPerPage;
502  if (fromPageIndex == toPageIndex) {
503    [self updatePageContent:fromPageIndex
504                 resetModel:NO];  // Just reorder items.
505    return;
506  }
507
508  if (fromPageIndex > toPageIndex)
509    std::swap(fromPageIndex, toPageIndex);
510
511  for (size_t i = fromPageIndex; i <= toPageIndex; ++i) {
512    [self updatePageContent:i
513                 resetModel:YES];
514  }
515}
516
517// Compare with views implementation in AppsGridView::MoveItemInModel().
518- (void)moveItemWithIndex:(size_t)itemIndex
519             toModelIndex:(size_t)modelIndex {
520  // Ingore no-op moves. Note that this is always the case when canceled.
521  if (itemIndex == modelIndex)
522    return;
523
524  app_list::AppListItemList* itemList = [self model]->item_list();
525  itemList->RemoveObserver(bridge_.get());
526  itemList->MoveItem(itemIndex, modelIndex);
527  itemList->AddObserver(bridge_.get());
528}
529
530- (AppsCollectionViewDragManager*)dragManager {
531  return dragManager_;
532}
533
534- (size_t)scheduledScrollPage {
535  return scheduledScrollPage_;
536}
537
538- (void)listItemAdded:(size_t)index
539                 item:(app_list::AppListItemModel*)itemModel {
540  // Cancel any drag, to ensure the model stays consistent.
541  [dragManager_ cancelDrag];
542
543  [items_ insertObject:[NSValue valueWithPointer:itemModel]
544              atIndex:index];
545
546  [self updatePages:index];
547}
548
549- (void)listItemRemoved:(size_t)index {
550  [dragManager_ cancelDrag];
551
552  // Clear the models explicitly to avoid surprises from autorelease.
553  [[self itemAtIndex:index] setModel:NULL];
554
555  [items_ removeObjectsInRange:NSMakeRange(index, 1)];
556  [self updatePages:index];
557}
558
559- (void)listItemMovedFromIndex:(size_t)fromIndex
560                  toModelIndex:(size_t)toIndex {
561  [dragManager_ cancelDrag];
562  [self moveItemInView:fromIndex
563           toItemIndex:toIndex];
564}
565
566- (CGFloat)visiblePortionOfPage:(int)page {
567  CGFloat scrollOffsetOfPage =
568      NSMinX([[[self gridScrollView] contentView] bounds]) / kViewWidth - page;
569  if (scrollOffsetOfPage <= -1.0 || scrollOffsetOfPage >= 1.0)
570    return 0.0;
571
572  if (scrollOffsetOfPage <= 0.0)
573    return scrollOffsetOfPage + 1.0;
574
575  return -1.0 + scrollOffsetOfPage;
576}
577
578- (void)onPagerClicked:(AppListPagerView*)sender {
579  int selectedSegment = [sender selectedSegment];
580  if (selectedSegment < 0)
581    return;  // No selection.
582
583  int pageIndex = [[sender cell] tagForSegment:selectedSegment];
584  if (pageIndex >= 0)
585    [self scrollToPage:pageIndex];
586}
587
588- (BOOL)moveSelectionByDelta:(int)indexDelta {
589  if (indexDelta == 0)
590    return NO;
591
592  NSUInteger oldIndex = [self selectedItemIndex];
593
594  // If nothing is currently selected, select the first item on the page.
595  if (oldIndex == NSNotFound) {
596    [self selectItemAtIndex:visiblePage_ * kItemsPerPage];
597    return YES;
598  }
599
600  // Can't select a negative index.
601  if (indexDelta < 0 && static_cast<NSUInteger>(-indexDelta) > oldIndex)
602    return NO;
603
604  // Can't select an index greater or equal to the number of items.
605  if (oldIndex + indexDelta >= [items_ count]) {
606    if (visiblePage_ == [pages_ count] - 1)
607      return NO;
608
609    // If we're not on the last page, then select the last item.
610    [self selectItemAtIndex:[items_ count] - 1];
611    return YES;
612  }
613
614  [self selectItemAtIndex:oldIndex + indexDelta];
615  return YES;
616}
617
618- (void)selectItemAtIndex:(NSUInteger)index {
619  if (index >= [items_ count])
620    return;
621
622  if (index / kItemsPerPage != visiblePage_)
623    [self scrollToPage:index / kItemsPerPage];
624
625  [[self itemAtIndex:index] setSelected:YES];
626}
627
628- (BOOL)handleCommandBySelector:(SEL)command {
629  if (command == @selector(insertNewline:) ||
630      command == @selector(insertLineBreak:)) {
631    [self activateSelection];
632    return YES;
633  }
634
635  NSUInteger oldIndex = [self selectedItemIndex];
636  // If nothing is currently selected, select the first item on the page.
637  if (oldIndex == NSNotFound) {
638    [self selectItemAtIndex:visiblePage_ * kItemsPerPage];
639    return YES;
640  }
641
642  if (command == @selector(moveLeft:)) {
643    return oldIndex % kFixedColumns == 0 ?
644        [self moveSelectionByDelta:-kItemsPerPage + kFixedColumns - 1] :
645        [self moveSelectionByDelta:-1];
646  }
647
648  if (command == @selector(moveRight:)) {
649    return oldIndex % kFixedColumns == kFixedColumns - 1 ?
650        [self moveSelectionByDelta:+kItemsPerPage - kFixedColumns + 1] :
651        [self moveSelectionByDelta:1];
652  }
653
654  if (command == @selector(moveUp:)) {
655    return oldIndex / kFixedColumns % kFixedRows == 0 ?
656        NO : [self moveSelectionByDelta:-kFixedColumns];
657  }
658
659  if (command == @selector(moveDown:)) {
660    return oldIndex / kFixedColumns % kFixedRows == kFixedRows - 1 ?
661        NO : [self moveSelectionByDelta:kFixedColumns];
662  }
663
664  if (command == @selector(pageUp:) ||
665      command == @selector(scrollPageUp:))
666    return [self moveSelectionByDelta:-kItemsPerPage];
667
668  if (command == @selector(pageDown:) ||
669      command == @selector(scrollPageDown:))
670    return [self moveSelectionByDelta:kItemsPerPage];
671
672  return NO;
673}
674
675@end
676