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.
5 #import "ui/app_list/cocoa/apps_grid_controller.h"
7 #include "base/mac/foundation_util.h"
8 #include "ui/app_list/app_list_item.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"
19 // OSX app list has hardcoded rows and columns for now.
20 const int kFixedRows = 4;
21 const int kFixedColumns = 4;
22 const int kItemsPerPage = kFixedRows * kFixedColumns;
24 // Padding space in pixels for fixed layout.
25 const CGFloat kGridTopPadding = 1;
26 const CGFloat kLeftRightPadding = 21;
27 const CGFloat kScrollerPadding = 16;
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.
31 const CGFloat kPreferredTileWidth = 88;
32 const CGFloat kPreferredTileHeight = 98;
34 const CGFloat kViewWidth =
35 kFixedColumns * kPreferredTileWidth + 2 * kLeftRightPadding;
36 const CGFloat kViewHeight = kFixedRows * kPreferredTileHeight;
38 const NSTimeInterval kScrollWhileDraggingDelay = 1.0;
39 NSTimeInterval g_scroll_duration = 0.18;
43 @interface AppsGridController ()
45 - (void)scrollToPageWithTimer:(size_t)targetPage;
46 - (void)onTimer:(NSTimer*)theTimer;
48 // Cancel a currently running scroll animation.
49 - (void)cancelScrollAnimation;
51 // Index of the page with the most content currently visible.
52 - (size_t)nearestPageIndex;
54 // Bootstrap the views this class controls.
55 - (void)loadAndSetView;
57 - (void)boundsDidChange:(NSNotification*)notification;
59 // Action for buttons in the grid.
60 - (void)onItemClicked:(id)sender;
62 - (AppsGridViewItem*)itemAtPageIndex:(size_t)pageIndex
63 indexInPage:(size_t)indexInPage;
65 // Return the button of the selected item.
66 - (NSButton*)selectedButton;
68 // The scroll view holding the grid pages.
69 - (NSScrollView*)gridScrollView;
71 - (NSView*)pagesContainerView;
73 // Create any new pages after updating |items_|.
74 - (void)updatePages:(size_t)startItemIndex;
76 - (void)updatePageContent:(size_t)pageIndex
77 resetModel:(BOOL)resetModel;
79 // Bridged methods for AppListItemListObserver.
80 - (void)listItemAdded:(size_t)index
81 item:(app_list::AppListItem*)item;
83 - (void)listItemRemoved:(size_t)index;
85 - (void)listItemMovedFromIndex:(size_t)fromIndex
86 toModelIndex:(size_t)toIndex;
88 // Moves the selection by |indexDelta| items.
89 - (BOOL)moveSelectionByDelta:(int)indexDelta;
95 class AppsGridDelegateBridge : public AppListItemListObserver {
97 AppsGridDelegateBridge(AppsGridController* parent) : parent_(parent) {}
100 // Overridden from AppListItemListObserver:
101 void OnListItemAdded(size_t index, AppListItem* item) override {
102 [parent_ listItemAdded:index
105 void OnListItemRemoved(size_t index, AppListItem* item) override {
106 [parent_ listItemRemoved:index];
108 void OnListItemMoved(size_t from_index,
110 AppListItem* item) override {
111 [parent_ listItemMovedFromIndex:from_index
112 toModelIndex:to_index];
114 void OnAppListItemHighlight(size_t index, bool highlight) override {
115 // NSCollectionView (or -[AppsGridController scrollToPage]) ensures only one
116 // item is highlighted, so clearing a highlight isn't necessary.
120 [parent_ selectItemAtIndex:index];
121 [parent_ scrollToPage:index / kItemsPerPage];
124 AppsGridController* parent_; // Weak, owns us.
126 DISALLOW_COPY_AND_ASSIGN(AppsGridDelegateBridge);
129 } // namespace app_list
131 @interface PageContainerView : NSView;
134 // The container view needs to flip coordinates so that it is laid out
135 // correctly whether or not there is a horizontal scrollbar.
136 @implementation PageContainerView
144 @implementation AppsGridController
146 + (void)setScrollAnimationDuration:(NSTimeInterval)duration {
147 g_scroll_duration = duration;
150 + (CGFloat)scrollerPadding {
151 return kScrollerPadding;
154 @synthesize paginationObserver = paginationObserver_;
157 if ((self = [super init])) {
158 bridge_.reset(new app_list::AppsGridDelegateBridge(self));
159 NSSize cellSize = NSMakeSize(kPreferredTileWidth, kPreferredTileHeight);
161 [[AppsCollectionViewDragManager alloc] initWithCellSize:cellSize
163 columns:kFixedColumns
164 gridController:self]);
165 pages_.reset([[NSMutableArray alloc] init]);
166 items_.reset([[NSMutableArray alloc] init]);
167 [self loadAndSetView];
168 [self updatePages:0];
174 [[NSNotificationCenter defaultCenter] removeObserver:self];
178 - (NSCollectionView*)collectionViewAtPageIndex:(size_t)pageIndex {
179 return [pages_ objectAtIndex:pageIndex];
182 - (size_t)pageIndexForCollectionView:(NSCollectionView*)page {
183 for (size_t pageIndex = 0; pageIndex < [pages_ count]; ++pageIndex) {
184 if (page == [self collectionViewAtPageIndex:pageIndex])
190 - (app_list::AppListModel*)model {
191 return delegate_ ? delegate_->GetModel() : NULL;
194 - (void)setDelegate:(app_list::AppListViewDelegate*)newDelegate {
196 app_list::AppListModel* oldModel = delegate_->GetModel();
198 oldModel->top_level_item_list()->RemoveObserver(bridge_.get());
201 // Since the old model may be getting deleted, and the AppKit objects might
202 // be sitting in an NSAutoreleasePool, ensure there are no references to
204 for (size_t i = 0; i < [items_ count]; ++i)
205 [[self itemAtIndex:i] setModel:NULL];
207 [items_ removeAllObjects];
208 [self updatePages:0];
209 [self scrollToPage:0];
211 delegate_ = newDelegate;
215 app_list::AppListModel* newModel = delegate_->GetModel();
219 newModel->top_level_item_list()->AddObserver(bridge_.get());
220 for (size_t i = 0; i < newModel->top_level_item_list()->item_count(); ++i) {
221 app_list::AppListItem* itemModel =
222 newModel->top_level_item_list()->item_at(i);
223 [items_ insertObject:[NSValue valueWithPointer:itemModel]
226 [self updatePages:0];
229 - (size_t)visiblePage {
233 - (void)activateSelection {
234 [[self selectedButton] performClick:self];
237 - (size_t)pageCount {
238 return [pages_ count];
241 - (size_t)itemCount {
242 return [items_ count];
245 - (void)scrollToPage:(size_t)pageIndex {
246 NSClipView* clipView = [[self gridScrollView] contentView];
247 NSPoint newOrigin = [clipView bounds].origin;
249 // Scrolling outside of this range is edge elasticity, which animates
251 if ((pageIndex == 0 && (newOrigin.x <= 0)) ||
252 (pageIndex + 1 == [self pageCount] &&
253 newOrigin.x >= pageIndex * kViewWidth)) {
257 // Clear any selection on the current page (unless it has been removed).
258 if (visiblePage_ < [pages_ count]) {
259 [[self collectionViewAtPageIndex:visiblePage_]
260 setSelectionIndexes:[NSIndexSet indexSet]];
263 newOrigin.x = pageIndex * kViewWidth;
264 [NSAnimationContext beginGrouping];
265 [[NSAnimationContext currentContext] setDuration:g_scroll_duration];
266 [[clipView animator] setBoundsOrigin:newOrigin];
267 [NSAnimationContext endGrouping];
268 animatingScroll_ = YES;
269 targetScrollPage_ = pageIndex;
270 [self cancelScrollTimer];
273 - (void)maybeChangePageForPoint:(NSPoint)locationInWindow {
274 NSPoint pointInView = [[self view] convertPoint:locationInWindow
276 // Check if the point is outside the view on the left or right.
277 if (pointInView.x <= 0 || pointInView.x >= NSWidth([[self view] bounds])) {
278 size_t targetPage = visiblePage_;
279 if (pointInView.x <= 0)
280 targetPage -= targetPage != 0 ? 1 : 0;
282 targetPage += targetPage < [pages_ count] - 1 ? 1 : 0;
283 [self scrollToPageWithTimer:targetPage];
287 if (paginationObserver_) {
289 [paginationObserver_ pagerSegmentAtLocation:locationInWindow];
290 if (segment >= 0 && static_cast<size_t>(segment) != targetScrollPage_) {
291 [self scrollToPageWithTimer:segment];
296 // Otherwise the point may have moved back into the view.
297 [self cancelScrollTimer];
300 - (void)cancelScrollTimer {
301 scheduledScrollPage_ = targetScrollPage_;
302 [scrollWhileDraggingTimer_ invalidate];
305 - (void)scrollToPageWithTimer:(size_t)targetPage {
306 if (targetPage == targetScrollPage_) {
307 [self cancelScrollTimer];
311 if (targetPage == scheduledScrollPage_)
314 scheduledScrollPage_ = targetPage;
315 [scrollWhileDraggingTimer_ invalidate];
316 scrollWhileDraggingTimer_.reset(
317 [[NSTimer scheduledTimerWithTimeInterval:kScrollWhileDraggingDelay
319 selector:@selector(onTimer:)
321 repeats:NO] retain]);
324 - (void)onTimer:(NSTimer*)theTimer {
325 if (scheduledScrollPage_ == targetScrollPage_)
326 return; // Already animating scroll.
328 [self scrollToPage:scheduledScrollPage_];
331 - (void)cancelScrollAnimation {
332 NSClipView* clipView = [[self gridScrollView] contentView];
333 [NSAnimationContext beginGrouping];
334 [[NSAnimationContext currentContext] setDuration:0];
335 [[clipView animator] setBoundsOrigin:[clipView bounds].origin];
336 [NSAnimationContext endGrouping];
337 animatingScroll_ = NO;
340 - (size_t)nearestPageIndex {
342 NSMinX([[[self gridScrollView] contentView] bounds]) / kViewWidth);
345 - (void)userScrolling:(BOOL)isScrolling {
347 if (animatingScroll_)
348 [self cancelScrollAnimation];
350 [self scrollToPage:[self nearestPageIndex]];
354 - (void)loadAndSetView {
355 base::scoped_nsobject<PageContainerView> pagesContainer(
356 [[PageContainerView alloc] initWithFrame:NSZeroRect]);
358 NSRect scrollFrame = NSMakeRect(0, kGridTopPadding, kViewWidth,
359 kViewHeight + kScrollerPadding);
360 base::scoped_nsobject<ScrollViewWithNoScrollbars> scrollView(
361 [[ScrollViewWithNoScrollbars alloc] initWithFrame:scrollFrame]);
362 [scrollView setBorderType:NSNoBorder];
363 [scrollView setLineScroll:kViewWidth];
364 [scrollView setPageScroll:kViewWidth];
365 [scrollView setDelegate:self];
366 [scrollView setDocumentView:pagesContainer];
367 [scrollView setDrawsBackground:NO];
369 [[NSNotificationCenter defaultCenter]
371 selector:@selector(boundsDidChange:)
372 name:NSViewBoundsDidChangeNotification
373 object:[scrollView contentView]];
375 [self setView:scrollView];
378 - (void)boundsDidChange:(NSNotification*)notification {
379 size_t newPage = [self nearestPageIndex];
380 if (newPage == visiblePage_) {
381 [paginationObserver_ pageVisibilityChanged];
385 visiblePage_ = newPage;
386 [paginationObserver_ selectedPageChanged:newPage];
387 [paginationObserver_ pageVisibilityChanged];
390 - (void)onItemClicked:(id)sender {
391 for (size_t i = 0; i < [items_ count]; ++i) {
392 AppsGridViewItem* gridItem = [self itemAtIndex:i];
393 if ([[gridItem button] isEqual:sender])
394 [gridItem model]->Activate(0);
398 - (AppsGridViewItem*)itemAtPageIndex:(size_t)pageIndex
399 indexInPage:(size_t)indexInPage {
400 return base::mac::ObjCCastStrict<AppsGridViewItem>(
401 [[self collectionViewAtPageIndex:pageIndex] itemAtIndex:indexInPage]);
404 - (AppsGridViewItem*)itemAtIndex:(size_t)itemIndex {
405 const size_t pageIndex = itemIndex / kItemsPerPage;
406 return [self itemAtPageIndex:pageIndex
407 indexInPage:itemIndex - pageIndex * kItemsPerPage];
410 - (NSUInteger)selectedItemIndex {
411 NSCollectionView* page = [self collectionViewAtPageIndex:visiblePage_];
412 NSUInteger indexOnPage = [[page selectionIndexes] firstIndex];
413 if (indexOnPage == NSNotFound)
416 return indexOnPage + visiblePage_ * kItemsPerPage;
419 - (NSButton*)selectedButton {
420 NSUInteger index = [self selectedItemIndex];
421 if (index == NSNotFound)
424 return [[self itemAtIndex:index] button];
427 - (NSScrollView*)gridScrollView {
428 return base::mac::ObjCCastStrict<NSScrollView>([self view]);
431 - (NSView*)pagesContainerView {
432 return [[self gridScrollView] documentView];
435 - (void)updatePages:(size_t)startItemIndex {
436 // Note there is always at least one page.
437 size_t targetPages = 1;
438 if ([items_ count] != 0)
439 targetPages = ([items_ count] - 1) / kItemsPerPage + 1;
441 const size_t currentPages = [self pageCount];
442 // First see if the number of pages have changed.
443 if (targetPages != currentPages) {
444 if (targetPages < currentPages) {
445 // Pages need to be removed.
446 [pages_ removeObjectsInRange:NSMakeRange(targetPages,
447 currentPages - targetPages)];
449 // Pages need to be added.
450 for (size_t i = currentPages; i < targetPages; ++i) {
451 NSRect pageFrame = NSMakeRect(
452 kLeftRightPadding + kViewWidth * i, 0,
453 kViewWidth, kViewHeight);
454 [pages_ addObject:[dragManager_ makePageWithFrame:pageFrame]];
458 [[self pagesContainerView] setSubviews:pages_];
459 NSSize pagesSize = NSMakeSize(kViewWidth * targetPages, kViewHeight);
460 [[self pagesContainerView] setFrameSize:pagesSize];
461 [paginationObserver_ totalPagesChanged];
464 const size_t startPage = startItemIndex / kItemsPerPage;
465 // All pages on or after |startPage| may need items added or removed.
466 for (size_t pageIndex = startPage; pageIndex < targetPages; ++pageIndex) {
467 [self updatePageContent:pageIndex
472 - (void)updatePageContent:(size_t)pageIndex
473 resetModel:(BOOL)resetModel {
474 NSCollectionView* pageView = [self collectionViewAtPageIndex:pageIndex];
476 // Clear the models first, otherwise removed items could be autoreleased at
477 // an unknown point in the future, when the model owner may have gone away.
478 for (size_t i = 0; i < [[pageView content] count]; ++i) {
479 AppsGridViewItem* gridItem = base::mac::ObjCCastStrict<AppsGridViewItem>(
480 [pageView itemAtIndex:i]);
481 [gridItem setModel:NULL];
485 NSRange inPageRange = NSIntersectionRange(
486 NSMakeRange(pageIndex * kItemsPerPage, kItemsPerPage),
487 NSMakeRange(0, [items_ count]));
488 NSArray* pageContent = [items_ subarrayWithRange:inPageRange];
489 [pageView setContent:pageContent];
493 for (size_t i = 0; i < [pageContent count]; ++i) {
494 AppsGridViewItem* gridItem = base::mac::ObjCCastStrict<AppsGridViewItem>(
495 [pageView itemAtIndex:i]);
496 [gridItem setModel:static_cast<app_list::AppListItem*>(
497 [[pageContent objectAtIndex:i] pointerValue])];
501 - (void)moveItemInView:(size_t)fromIndex
502 toItemIndex:(size_t)toIndex {
503 base::scoped_nsobject<NSValue> item(
504 [[items_ objectAtIndex:fromIndex] retain]);
505 [items_ removeObjectAtIndex:fromIndex];
506 [items_ insertObject:item
509 size_t fromPageIndex = fromIndex / kItemsPerPage;
510 size_t toPageIndex = toIndex / kItemsPerPage;
511 if (fromPageIndex == toPageIndex) {
512 [self updatePageContent:fromPageIndex
513 resetModel:NO]; // Just reorder items.
517 if (fromPageIndex > toPageIndex)
518 std::swap(fromPageIndex, toPageIndex);
520 for (size_t i = fromPageIndex; i <= toPageIndex; ++i) {
521 [self updatePageContent:i
526 // Compare with views implementation in AppsGridView::MoveItemInModel().
527 - (void)moveItemWithIndex:(size_t)itemIndex
528 toModelIndex:(size_t)modelIndex {
529 // Ingore no-op moves. Note that this is always the case when canceled.
530 if (itemIndex == modelIndex)
533 app_list::AppListItemList* itemList = [self model]->top_level_item_list();
534 itemList->RemoveObserver(bridge_.get());
535 itemList->MoveItem(itemIndex, modelIndex);
536 itemList->AddObserver(bridge_.get());
539 - (AppsCollectionViewDragManager*)dragManager {
543 - (size_t)scheduledScrollPage {
544 return scheduledScrollPage_;
547 - (void)listItemAdded:(size_t)index
548 item:(app_list::AppListItem*)itemModel {
549 // Cancel any drag, to ensure the model stays consistent.
550 [dragManager_ cancelDrag];
552 [items_ insertObject:[NSValue valueWithPointer:itemModel]
555 [self updatePages:index];
558 - (void)listItemRemoved:(size_t)index {
559 [dragManager_ cancelDrag];
561 // Clear the models explicitly to avoid surprises from autorelease.
562 [[self itemAtIndex:index] setModel:NULL];
564 [items_ removeObjectsInRange:NSMakeRange(index, 1)];
565 [self updatePages:index];
568 - (void)listItemMovedFromIndex:(size_t)fromIndex
569 toModelIndex:(size_t)toIndex {
570 [dragManager_ cancelDrag];
571 [self moveItemInView:fromIndex
572 toItemIndex:toIndex];
575 - (CGFloat)visiblePortionOfPage:(int)page {
576 CGFloat scrollOffsetOfPage =
577 NSMinX([[[self gridScrollView] contentView] bounds]) / kViewWidth - page;
578 if (scrollOffsetOfPage <= -1.0 || scrollOffsetOfPage >= 1.0)
581 if (scrollOffsetOfPage <= 0.0)
582 return scrollOffsetOfPage + 1.0;
584 return -1.0 + scrollOffsetOfPage;
587 - (void)onPagerClicked:(AppListPagerView*)sender {
588 int selectedSegment = [sender selectedSegment];
589 if (selectedSegment < 0)
590 return; // No selection.
592 int pageIndex = [[sender cell] tagForSegment:selectedSegment];
594 [self scrollToPage:pageIndex];
597 - (BOOL)moveSelectionByDelta:(int)indexDelta {
601 NSUInteger oldIndex = [self selectedItemIndex];
603 // If nothing is currently selected, select the first item on the page.
604 if (oldIndex == NSNotFound) {
605 [self selectItemAtIndex:visiblePage_ * kItemsPerPage];
609 // Can't select a negative index.
610 if (indexDelta < 0 && static_cast<NSUInteger>(-indexDelta) > oldIndex)
613 // Can't select an index greater or equal to the number of items.
614 if (oldIndex + indexDelta >= [items_ count]) {
615 if (visiblePage_ == [pages_ count] - 1)
618 // If we're not on the last page, then select the last item.
619 [self selectItemAtIndex:[items_ count] - 1];
623 [self selectItemAtIndex:oldIndex + indexDelta];
627 - (void)selectItemAtIndex:(NSUInteger)index {
628 if (index >= [items_ count])
631 if (index / kItemsPerPage != visiblePage_)
632 [self scrollToPage:index / kItemsPerPage];
634 [[self itemAtIndex:index] setSelected:YES];
637 - (BOOL)handleCommandBySelector:(SEL)command {
638 if (command == @selector(insertNewline:) ||
639 command == @selector(insertLineBreak:)) {
640 [self activateSelection];
644 NSUInteger oldIndex = [self selectedItemIndex];
645 // If nothing is currently selected, select the first item on the page.
646 if (oldIndex == NSNotFound) {
647 [self selectItemAtIndex:visiblePage_ * kItemsPerPage];
651 if (command == @selector(moveLeft:)) {
652 return oldIndex % kFixedColumns == 0 ?
653 [self moveSelectionByDelta:-kItemsPerPage + kFixedColumns - 1] :
654 [self moveSelectionByDelta:-1];
657 if (command == @selector(moveRight:)) {
658 return oldIndex % kFixedColumns == kFixedColumns - 1 ?
659 [self moveSelectionByDelta:+kItemsPerPage - kFixedColumns + 1] :
660 [self moveSelectionByDelta:1];
663 if (command == @selector(moveUp:)) {
664 return oldIndex / kFixedColumns % kFixedRows == 0 ?
665 NO : [self moveSelectionByDelta:-kFixedColumns];
668 if (command == @selector(moveDown:)) {
669 return oldIndex / kFixedColumns % kFixedRows == kFixedRows - 1 ?
670 NO : [self moveSelectionByDelta:kFixedColumns];
673 if (command == @selector(pageUp:) ||
674 command == @selector(scrollPageUp:))
675 return [self moveSelectionByDelta:-kItemsPerPage];
677 if (command == @selector(pageDown:) ||
678 command == @selector(scrollPageDown:))
679 return [self moveSelectionByDelta:kItemsPerPage];