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