apps_grid_controller.mm revision 1e9bf3e0803691d0a228da41fc608347b6db4340
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  [self setModel:scoped_ptr<app_list::AppListModel>()];
168  [super dealloc];
169}
170
171- (NSCollectionView*)collectionViewAtPageIndex:(size_t)pageIndex {
172  return [pages_ objectAtIndex:pageIndex];
173}
174
175- (size_t)pageIndexForCollectionView:(NSCollectionView*)page {
176  for (size_t pageIndex = 0; pageIndex < [pages_ count]; ++pageIndex) {
177    if (page == [self collectionViewAtPageIndex:pageIndex])
178      return pageIndex;
179  }
180  return NSNotFound;
181}
182
183- (app_list::AppListModel*)model {
184  return model_.get();
185}
186
187- (void)setModel:(scoped_ptr<app_list::AppListModel>)newModel {
188  if (model_) {
189    model_->item_list()->RemoveObserver(bridge_.get());
190
191    // Since the model is about to be deleted, and the AppKit objects might be
192    // sitting in an NSAutoreleasePool, ensure there are no references to the
193    // model.
194    for (size_t i = 0; i < [items_ count]; ++i)
195      [[self itemAtIndex:i] setModel:NULL];
196
197    [items_ removeAllObjects];
198    [self updatePages:0];
199    [self scrollToPage:0];
200  }
201
202  model_.reset(newModel.release());
203  if (!model_)
204    return;
205
206  model_->item_list()->AddObserver(bridge_.get());
207  for (size_t i = 0; i < model_->item_list()->item_count(); ++i) {
208    app_list::AppListItemModel* itemModel = model_->item_list()->item_at(i);
209    [items_ insertObject:[NSValue valueWithPointer:itemModel]
210                 atIndex:i];
211  }
212  [self updatePages:0];
213}
214
215- (void)setDelegate:(app_list::AppListViewDelegate*)newDelegate {
216  scoped_ptr<app_list::AppListModel> newModel(new app_list::AppListModel);
217  delegate_ = newDelegate;
218  if (delegate_)
219    delegate_->InitModel(newModel.get());  // Populates items.
220  [self setModel:newModel.Pass()];
221}
222
223- (size_t)visiblePage {
224  return visiblePage_;
225}
226
227- (void)activateSelection {
228  [[self selectedButton] performClick:self];
229}
230
231- (size_t)pageCount {
232  return [pages_ count];
233}
234
235- (size_t)itemCount {
236  return [items_ count];
237}
238
239- (void)scrollToPage:(size_t)pageIndex {
240  NSClipView* clipView = [[self gridScrollView] contentView];
241  NSPoint newOrigin = [clipView bounds].origin;
242
243  // Scrolling outside of this range is edge elasticity, which animates
244  // automatically.
245  if ((pageIndex == 0 && (newOrigin.x <= 0)) ||
246      (pageIndex + 1 == [self pageCount] &&
247          newOrigin.x >= pageIndex * kViewWidth)) {
248    return;
249  }
250
251  // Clear any selection on the current page (unless it has been removed).
252  if (visiblePage_ < [pages_ count]) {
253    [[self collectionViewAtPageIndex:visiblePage_]
254        setSelectionIndexes:[NSIndexSet indexSet]];
255  }
256
257  newOrigin.x = pageIndex * kViewWidth;
258  [NSAnimationContext beginGrouping];
259  [[NSAnimationContext currentContext] setDuration:g_scroll_duration];
260  [[clipView animator] setBoundsOrigin:newOrigin];
261  [NSAnimationContext endGrouping];
262  animatingScroll_ = YES;
263  targetScrollPage_ = pageIndex;
264  [self cancelScrollTimer];
265}
266
267- (void)maybeChangePageForPoint:(NSPoint)locationInWindow {
268  NSPoint pointInView = [[self view] convertPoint:locationInWindow
269                                         fromView:nil];
270  // Check if the point is outside the view on the left or right.
271  if (pointInView.x <= 0 || pointInView.x >= NSWidth([[self view] bounds])) {
272    size_t targetPage = visiblePage_;
273    if (pointInView.x <= 0)
274      targetPage -= targetPage != 0 ? 1 : 0;
275    else
276      targetPage += targetPage < [pages_ count] - 1 ? 1 : 0;
277    [self scrollToPageWithTimer:targetPage];
278    return;
279  }
280
281  if (paginationObserver_) {
282    NSInteger segment =
283        [paginationObserver_ pagerSegmentAtLocation:locationInWindow];
284    if (segment >= 0 && static_cast<size_t>(segment) != targetScrollPage_) {
285      [self scrollToPageWithTimer:segment];
286      return;
287    }
288  }
289
290  // Otherwise the point may have moved back into the view.
291  [self cancelScrollTimer];
292}
293
294- (void)cancelScrollTimer {
295  scheduledScrollPage_ = targetScrollPage_;
296  [scrollWhileDraggingTimer_ invalidate];
297}
298
299- (void)scrollToPageWithTimer:(size_t)targetPage {
300  if (targetPage == targetScrollPage_) {
301    [self cancelScrollTimer];
302    return;
303  }
304
305  if (targetPage == scheduledScrollPage_)
306    return;
307
308  scheduledScrollPage_ = targetPage;
309  [scrollWhileDraggingTimer_ invalidate];
310  scrollWhileDraggingTimer_.reset(
311      [[NSTimer scheduledTimerWithTimeInterval:kScrollWhileDraggingDelay
312                                        target:self
313                                      selector:@selector(onTimer:)
314                                      userInfo:nil
315                                       repeats:NO] retain]);
316}
317
318- (void)onTimer:(NSTimer*)theTimer {
319  if (scheduledScrollPage_ == targetScrollPage_)
320    return;  // Already animating scroll.
321
322  [self scrollToPage:scheduledScrollPage_];
323}
324
325- (void)cancelScrollAnimation {
326  NSClipView* clipView = [[self gridScrollView] contentView];
327  [NSAnimationContext beginGrouping];
328  [[NSAnimationContext currentContext] setDuration:0];
329  [[clipView animator] setBoundsOrigin:[clipView bounds].origin];
330  [NSAnimationContext endGrouping];
331  animatingScroll_ = NO;
332}
333
334- (size_t)nearestPageIndex {
335  return lround(
336      NSMinX([[[self gridScrollView] contentView] bounds]) / kViewWidth);
337}
338
339- (void)userScrolling:(BOOL)isScrolling {
340  if (isScrolling) {
341    if (animatingScroll_)
342      [self cancelScrollAnimation];
343  } else {
344    [self scrollToPage:[self nearestPageIndex]];
345  }
346}
347
348- (void)loadAndSetView {
349  base::scoped_nsobject<PageContainerView> pagesContainer(
350      [[PageContainerView alloc] initWithFrame:NSZeroRect]);
351
352  NSRect scrollFrame = NSMakeRect(0, kGridTopPadding, kViewWidth,
353                                  kViewHeight + kScrollerPadding);
354  base::scoped_nsobject<ScrollViewWithNoScrollbars> scrollView(
355      [[ScrollViewWithNoScrollbars alloc] initWithFrame:scrollFrame]);
356  [scrollView setBorderType:NSNoBorder];
357  [scrollView setLineScroll:kViewWidth];
358  [scrollView setPageScroll:kViewWidth];
359  [scrollView setDelegate:self];
360  [scrollView setDocumentView:pagesContainer];
361  [scrollView setDrawsBackground:NO];
362
363  [[NSNotificationCenter defaultCenter]
364      addObserver:self
365         selector:@selector(boundsDidChange:)
366             name:NSViewBoundsDidChangeNotification
367           object:[scrollView contentView]];
368
369  [self setView:scrollView];
370}
371
372- (void)boundsDidChange:(NSNotification*)notification {
373  size_t newPage = [self nearestPageIndex];
374  if (newPage == visiblePage_) {
375    [paginationObserver_ pageVisibilityChanged];
376    return;
377  }
378
379  visiblePage_ = newPage;
380  [paginationObserver_ selectedPageChanged:newPage];
381  [paginationObserver_ pageVisibilityChanged];
382}
383
384- (void)onItemClicked:(id)sender {
385  for (size_t i = 0; i < [items_ count]; ++i) {
386    AppsGridViewItem* item = [self itemAtIndex:i];
387    if ([[item button] isEqual:sender])
388      [item model]->Activate(0);
389  }
390}
391
392- (AppsGridViewItem*)itemAtPageIndex:(size_t)pageIndex
393                         indexInPage:(size_t)indexInPage {
394  return base::mac::ObjCCastStrict<AppsGridViewItem>(
395      [[self collectionViewAtPageIndex:pageIndex] itemAtIndex:indexInPage]);
396}
397
398- (AppsGridViewItem*)itemAtIndex:(size_t)itemIndex {
399  const size_t pageIndex = itemIndex / kItemsPerPage;
400  return [self itemAtPageIndex:pageIndex
401                   indexInPage:itemIndex - pageIndex * kItemsPerPage];
402}
403
404- (NSUInteger)selectedItemIndex {
405  NSCollectionView* page = [self collectionViewAtPageIndex:visiblePage_];
406  NSUInteger indexOnPage = [[page selectionIndexes] firstIndex];
407  if (indexOnPage == NSNotFound)
408    return NSNotFound;
409
410  return indexOnPage + visiblePage_ * kItemsPerPage;
411}
412
413- (NSButton*)selectedButton {
414  NSUInteger index = [self selectedItemIndex];
415  if (index == NSNotFound)
416    return nil;
417
418  return [[self itemAtIndex:index] button];
419}
420
421- (NSScrollView*)gridScrollView {
422  return base::mac::ObjCCastStrict<NSScrollView>([self view]);
423}
424
425- (NSView*)pagesContainerView {
426  return [[self gridScrollView] documentView];
427}
428
429- (void)updatePages:(size_t)startItemIndex {
430  // Note there is always at least one page.
431  size_t targetPages = 1;
432  if ([items_ count] != 0)
433    targetPages = ([items_ count] - 1) / kItemsPerPage + 1;
434
435  const size_t currentPages = [self pageCount];
436  // First see if the number of pages have changed.
437  if (targetPages != currentPages) {
438    if (targetPages < currentPages) {
439      // Pages need to be removed.
440      [pages_ removeObjectsInRange:NSMakeRange(targetPages,
441                                               currentPages - targetPages)];
442    } else {
443      // Pages need to be added.
444      for (size_t i = currentPages; i < targetPages; ++i) {
445        NSRect pageFrame = NSMakeRect(
446            kLeftRightPadding + kViewWidth * i, 0,
447            kViewWidth, kViewHeight);
448        [pages_ addObject:[dragManager_ makePageWithFrame:pageFrame]];
449      }
450    }
451
452    [[self pagesContainerView] setSubviews:pages_];
453    NSSize pagesSize = NSMakeSize(kViewWidth * targetPages, kViewHeight);
454    [[self pagesContainerView] setFrameSize:pagesSize];
455    [paginationObserver_ totalPagesChanged];
456  }
457
458  const size_t startPage = startItemIndex / kItemsPerPage;
459  // All pages on or after |startPage| may need items added or removed.
460  for (size_t pageIndex = startPage; pageIndex < targetPages; ++pageIndex) {
461    [self updatePageContent:pageIndex
462                 resetModel:YES];
463  }
464}
465
466- (void)updatePageContent:(size_t)pageIndex
467               resetModel:(BOOL)resetModel {
468  NSCollectionView* pageView = [self collectionViewAtPageIndex:pageIndex];
469  if (resetModel) {
470    // Clear the models first, otherwise removed items could be autoreleased at
471    // an unknown point in the future, when the model owner may have gone away.
472    for (size_t i = 0; i < [[pageView content] count]; ++i) {
473      AppsGridViewItem* item = base::mac::ObjCCastStrict<AppsGridViewItem>(
474          [pageView itemAtIndex:i]);
475      [item setModel:NULL];
476    }
477  }
478
479  NSRange inPageRange = NSIntersectionRange(
480      NSMakeRange(pageIndex * kItemsPerPage, kItemsPerPage),
481      NSMakeRange(0, [items_ count]));
482  NSArray* pageContent = [items_ subarrayWithRange:inPageRange];
483  [pageView setContent:pageContent];
484  if (!resetModel)
485    return;
486
487  for (size_t i = 0; i < [pageContent count]; ++i) {
488    AppsGridViewItem* item = base::mac::ObjCCastStrict<AppsGridViewItem>(
489        [pageView itemAtIndex:i]);
490    [item setModel:static_cast<app_list::AppListItemModel*>(
491        [[pageContent objectAtIndex:i] pointerValue])];
492  }
493}
494
495- (void)moveItemInView:(size_t)fromIndex
496           toItemIndex:(size_t)toIndex {
497  base::scoped_nsobject<NSValue> item(
498      [[items_ objectAtIndex:fromIndex] retain]);
499  [items_ removeObjectAtIndex:fromIndex];
500  [items_ insertObject:item
501               atIndex:toIndex];
502
503  size_t fromPageIndex = fromIndex / kItemsPerPage;
504  size_t toPageIndex = toIndex / kItemsPerPage;
505  if (fromPageIndex == toPageIndex) {
506    [self updatePageContent:fromPageIndex
507                 resetModel:NO];  // Just reorder items.
508    return;
509  }
510
511  if (fromPageIndex > toPageIndex)
512    std::swap(fromPageIndex, toPageIndex);
513
514  for (size_t i = fromPageIndex; i <= toPageIndex; ++i) {
515    [self updatePageContent:i
516                 resetModel:YES];
517  }
518}
519
520// Compare with views implementation in AppsGridView::MoveItemInModel().
521- (void)moveItemWithIndex:(size_t)itemIndex
522             toModelIndex:(size_t)modelIndex {
523  // Ingore no-op moves. Note that this is always the case when canceled.
524  if (itemIndex == modelIndex)
525    return;
526
527  model_->item_list()->RemoveObserver(bridge_.get());
528  model_->item_list()->MoveItem(itemIndex, modelIndex);
529  model_->item_list()->AddObserver(bridge_.get());
530}
531
532- (AppsCollectionViewDragManager*)dragManager {
533  return dragManager_;
534}
535
536- (size_t)scheduledScrollPage {
537  return scheduledScrollPage_;
538}
539
540- (void)listItemAdded:(size_t)index
541                 item:(app_list::AppListItemModel*)itemModel {
542  // Cancel any drag, to ensure the model stays consistent.
543  [dragManager_ cancelDrag];
544
545  [items_ insertObject:[NSValue valueWithPointer:itemModel]
546              atIndex:index];
547
548  [self updatePages:index];
549}
550
551- (void)listItemRemoved:(size_t)index {
552  [dragManager_ cancelDrag];
553
554  // Clear the models explicitly to avoid surprises from autorelease.
555  [[self itemAtIndex:index] setModel:NULL];
556
557  [items_ removeObjectsInRange:NSMakeRange(index, 1)];
558  [self updatePages:index];
559}
560
561- (void)listItemMovedFromIndex:(size_t)fromIndex
562                  toModelIndex:(size_t)toIndex {
563  [dragManager_ cancelDrag];
564  [self moveItemInView:fromIndex
565           toItemIndex:toIndex];
566}
567
568- (CGFloat)visiblePortionOfPage:(int)page {
569  CGFloat scrollOffsetOfPage =
570      NSMinX([[[self gridScrollView] contentView] bounds]) / kViewWidth - page;
571  if (scrollOffsetOfPage <= -1.0 || scrollOffsetOfPage >= 1.0)
572    return 0.0;
573
574  if (scrollOffsetOfPage <= 0.0)
575    return scrollOffsetOfPage + 1.0;
576
577  return -1.0 + scrollOffsetOfPage;
578}
579
580- (void)onPagerClicked:(AppListPagerView*)sender {
581  int selectedSegment = [sender selectedSegment];
582  if (selectedSegment < 0)
583    return;  // No selection.
584
585  int pageIndex = [[sender cell] tagForSegment:selectedSegment];
586  if (pageIndex >= 0)
587    [self scrollToPage:pageIndex];
588}
589
590- (BOOL)moveSelectionByDelta:(int)indexDelta {
591  if (indexDelta == 0)
592    return NO;
593
594  NSUInteger oldIndex = [self selectedItemIndex];
595
596  // If nothing is currently selected, select the first item on the page.
597  if (oldIndex == NSNotFound) {
598    [self selectItemAtIndex:visiblePage_ * kItemsPerPage];
599    return YES;
600  }
601
602  // Can't select a negative index.
603  if (indexDelta < 0 && static_cast<NSUInteger>(-indexDelta) > oldIndex)
604    return NO;
605
606  // Can't select an index greater or equal to the number of items.
607  if (oldIndex + indexDelta >= [items_ count]) {
608    if (visiblePage_ == [pages_ count] - 1)
609      return NO;
610
611    // If we're not on the last page, then select the last item.
612    [self selectItemAtIndex:[items_ count] - 1];
613    return YES;
614  }
615
616  [self selectItemAtIndex:oldIndex + indexDelta];
617  return YES;
618}
619
620- (void)selectItemAtIndex:(NSUInteger)index {
621  if (index >= [items_ count])
622    return;
623
624  if (index / kItemsPerPage != visiblePage_)
625    [self scrollToPage:index / kItemsPerPage];
626
627  [[self itemAtIndex:index] setSelected:YES];
628}
629
630- (BOOL)handleCommandBySelector:(SEL)command {
631  if (command == @selector(insertNewline:) ||
632      command == @selector(insertLineBreak:)) {
633    [self activateSelection];
634    return YES;
635  }
636
637  NSUInteger oldIndex = [self selectedItemIndex];
638  // If nothing is currently selected, select the first item on the page.
639  if (oldIndex == NSNotFound) {
640    [self selectItemAtIndex:visiblePage_ * kItemsPerPage];
641    return YES;
642  }
643
644  if (command == @selector(moveLeft:)) {
645    return oldIndex % kFixedColumns == 0 ?
646        [self moveSelectionByDelta:-kItemsPerPage + kFixedColumns - 1] :
647        [self moveSelectionByDelta:-1];
648  }
649
650  if (command == @selector(moveRight:)) {
651    return oldIndex % kFixedColumns == kFixedColumns - 1 ?
652        [self moveSelectionByDelta:+kItemsPerPage - kFixedColumns + 1] :
653        [self moveSelectionByDelta:1];
654  }
655
656  if (command == @selector(moveUp:)) {
657    return oldIndex / kFixedColumns % kFixedRows == 0 ?
658        NO : [self moveSelectionByDelta:-kFixedColumns];
659  }
660
661  if (command == @selector(moveDown:)) {
662    return oldIndex / kFixedColumns % kFixedRows == kFixedRows - 1 ?
663        NO : [self moveSelectionByDelta:kFixedColumns];
664  }
665
666  if (command == @selector(pageUp:) ||
667      command == @selector(scrollPageUp:))
668    return [self moveSelectionByDelta:-kItemsPerPage];
669
670  if (command == @selector(pageDown:) ||
671      command == @selector(scrollPageDown:))
672    return [self moveSelectionByDelta:kItemsPerPage];
673
674  return NO;
675}
676
677@end
678