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