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_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"
18 // OSX app list has hardcoded rows and columns for now.
19 const int kFixedRows = 4;
20 const int kFixedColumns = 4;
21 const int kItemsPerPage = kFixedRows * kFixedColumns;
23 // Padding space in pixels for fixed layout.
24 const CGFloat kGridTopPadding = 1;
25 const CGFloat kLeftRightPadding = 21;
26 const CGFloat kScrollerPadding = 16;
28 // Preferred tile size when showing in fixed layout. These should be even
29 // numbers to ensure that if they are grown 50% they remain integers.
30 const CGFloat kPreferredTileWidth = 88;
31 const CGFloat kPreferredTileHeight = 98;
33 const CGFloat kViewWidth =
34 kFixedColumns * kPreferredTileWidth + 2 * kLeftRightPadding;
35 const CGFloat kViewHeight = kFixedRows * kPreferredTileHeight;
37 const NSTimeInterval kScrollWhileDraggingDelay = 1.0;
38 NSTimeInterval g_scroll_duration = 0.18;
42 @interface AppsGridController ()
44 - (void)scrollToPageWithTimer:(size_t)targetPage;
45 - (void)onTimer:(NSTimer*)theTimer;
47 // Cancel a currently running scroll animation.
48 - (void)cancelScrollAnimation;
50 // Index of the page with the most content currently visible.
51 - (size_t)nearestPageIndex;
53 // Bootstrap the views this class controls.
54 - (void)loadAndSetView;
56 - (void)boundsDidChange:(NSNotification*)notification;
58 // Action for buttons in the grid.
59 - (void)onItemClicked:(id)sender;
61 - (AppsGridViewItem*)itemAtPageIndex:(size_t)pageIndex
62 indexInPage:(size_t)indexInPage;
64 // Return the button of the selected item.
65 - (NSButton*)selectedButton;
67 // The scroll view holding the grid pages.
68 - (NSScrollView*)gridScrollView;
70 - (NSView*)pagesContainerView;
72 // Create any new pages after updating |items_|.
73 - (void)updatePages:(size_t)startItemIndex;
75 - (void)updatePageContent:(size_t)pageIndex
76 resetModel:(BOOL)resetModel;
78 // Bridged methods for ui::ListModelObserver.
79 - (void)listItemsAdded:(size_t)start
82 - (void)listItemsRemoved:(size_t)start
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 ui::ListModelObserver {
97 AppsGridDelegateBridge(AppsGridController* parent) : parent_(parent) {}
100 // Overridden from ui::ListModelObserver:
101 virtual void ListItemsAdded(size_t start, size_t count) OVERRIDE {
102 [parent_ listItemsAdded:start
105 virtual void ListItemsRemoved(size_t start, size_t count) OVERRIDE {
106 [parent_ listItemsRemoved:start
109 virtual void ListItemMoved(size_t index, size_t target_index) OVERRIDE {
110 [parent_ listItemMovedFromIndex:index
111 toModelIndex:target_index];
113 virtual void ListItemsChanged(size_t start, size_t count) OVERRIDE {
117 AppsGridController* parent_; // Weak, owns us.
119 DISALLOW_COPY_AND_ASSIGN(AppsGridDelegateBridge);
122 } // namespace app_list
124 @interface PageContainerView : NSView;
127 // The container view needs to flip coordinates so that it is laid out
128 // correctly whether or not there is a horizontal scrollbar.
129 @implementation PageContainerView
137 @implementation AppsGridController
139 + (void)setScrollAnimationDuration:(NSTimeInterval)duration {
140 g_scroll_duration = duration;
143 + (CGFloat)scrollerPadding {
144 return kScrollerPadding;
147 @synthesize paginationObserver = paginationObserver_;
150 if ((self = [super init])) {
151 bridge_.reset(new app_list::AppsGridDelegateBridge(self));
152 NSSize cellSize = NSMakeSize(kPreferredTileWidth, kPreferredTileHeight);
154 [[AppsCollectionViewDragManager alloc] initWithCellSize:cellSize
156 columns:kFixedColumns
157 gridController:self]);
158 pages_.reset([[NSMutableArray alloc] init]);
159 items_.reset([[NSMutableArray alloc] init]);
160 [self loadAndSetView];
161 [self updatePages:0];
167 [[NSNotificationCenter defaultCenter] removeObserver:self];
168 [self setModel:scoped_ptr<app_list::AppListModel>()];
172 - (NSCollectionView*)collectionViewAtPageIndex:(size_t)pageIndex {
173 return [pages_ objectAtIndex:pageIndex];
176 - (size_t)pageIndexForCollectionView:(NSCollectionView*)page {
177 for (size_t pageIndex = 0; pageIndex < [pages_ count]; ++pageIndex) {
178 if (page == [self collectionViewAtPageIndex:pageIndex])
184 - (app_list::AppListModel*)model {
188 - (void)setModel:(scoped_ptr<app_list::AppListModel>)newModel {
190 model_->apps()->RemoveObserver(bridge_.get());
192 // Since the model is about to be deleted, and the AppKit objects might be
193 // sitting in an NSAutoreleasePool, ensure there are no references to the
195 for (size_t i = 0; i < [items_ count]; ++i)
196 [[self itemAtIndex:i] setModel:NULL];
198 [items_ removeAllObjects];
199 [self updatePages:0];
200 [self scrollToPage:0];
203 model_.reset(newModel.release());
207 model_->apps()->AddObserver(bridge_.get());
208 [self listItemsAdded:0
209 count:model_->apps()->item_count()];
212 - (void)setDelegate:(app_list::AppListViewDelegate*)newDelegate {
213 scoped_ptr<app_list::AppListModel> newModel(new app_list::AppListModel);
214 delegate_ = newDelegate;
216 delegate_->SetModel(newModel.get()); // Populates items.
217 [self setModel:newModel.Pass()];
220 - (size_t)visiblePage {
224 - (void)activateSelection {
225 [[self selectedButton] performClick:self];
228 - (size_t)pageCount {
229 return [pages_ count];
232 - (size_t)itemCount {
233 return [items_ count];
236 - (void)scrollToPage:(size_t)pageIndex {
237 NSClipView* clipView = [[self gridScrollView] contentView];
238 NSPoint newOrigin = [clipView bounds].origin;
240 // Scrolling outside of this range is edge elasticity, which animates
242 if ((pageIndex == 0 && (newOrigin.x <= 0)) ||
243 (pageIndex + 1 == [self pageCount] &&
244 newOrigin.x >= pageIndex * kViewWidth)) {
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]];
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];
264 - (void)maybeChangePageForPoint:(NSPoint)locationInWindow {
265 NSPoint pointInView = [[self view] convertPoint:locationInWindow
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;
273 targetPage += targetPage < [pages_ count] - 1 ? 1 : 0;
274 [self scrollToPageWithTimer:targetPage];
278 if (paginationObserver_) {
280 [paginationObserver_ pagerSegmentAtLocation:locationInWindow];
281 if (segment >= 0 && static_cast<size_t>(segment) != targetScrollPage_) {
282 [self scrollToPageWithTimer:segment];
287 // Otherwise the point may have moved back into the view.
288 [self cancelScrollTimer];
291 - (void)cancelScrollTimer {
292 scheduledScrollPage_ = targetScrollPage_;
293 [scrollWhileDraggingTimer_ invalidate];
296 - (void)scrollToPageWithTimer:(size_t)targetPage {
297 if (targetPage == targetScrollPage_) {
298 [self cancelScrollTimer];
302 if (targetPage == scheduledScrollPage_)
305 scheduledScrollPage_ = targetPage;
306 [scrollWhileDraggingTimer_ invalidate];
307 scrollWhileDraggingTimer_.reset(
308 [[NSTimer scheduledTimerWithTimeInterval:kScrollWhileDraggingDelay
310 selector:@selector(onTimer:)
312 repeats:NO] retain]);
315 - (void)onTimer:(NSTimer*)theTimer {
316 if (scheduledScrollPage_ == targetScrollPage_)
317 return; // Already animating scroll.
319 [self scrollToPage:scheduledScrollPage_];
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;
331 - (size_t)nearestPageIndex {
333 NSMinX([[[self gridScrollView] contentView] bounds]) / kViewWidth);
336 - (void)userScrolling:(BOOL)isScrolling {
338 if (animatingScroll_)
339 [self cancelScrollAnimation];
341 [self scrollToPage:[self nearestPageIndex]];
345 - (void)loadAndSetView {
346 base::scoped_nsobject<PageContainerView> pagesContainer(
347 [[PageContainerView alloc] initWithFrame:NSZeroRect]);
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];
360 [[NSNotificationCenter defaultCenter]
362 selector:@selector(boundsDidChange:)
363 name:NSViewBoundsDidChangeNotification
364 object:[scrollView contentView]];
366 [self setView:scrollView];
369 - (void)boundsDidChange:(NSNotification*)notification {
370 size_t newPage = [self nearestPageIndex];
371 if (newPage == visiblePage_) {
372 [paginationObserver_ pageVisibilityChanged];
376 visiblePage_ = newPage;
377 [paginationObserver_ selectedPageChanged:newPage];
378 [paginationObserver_ pageVisibilityChanged];
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 delegate_->ActivateAppListItem([item model], 0);
389 - (AppsGridViewItem*)itemAtPageIndex:(size_t)pageIndex
390 indexInPage:(size_t)indexInPage {
391 return base::mac::ObjCCastStrict<AppsGridViewItem>(
392 [[self collectionViewAtPageIndex:pageIndex] itemAtIndex:indexInPage]);
395 - (AppsGridViewItem*)itemAtIndex:(size_t)itemIndex {
396 const size_t pageIndex = itemIndex / kItemsPerPage;
397 return [self itemAtPageIndex:pageIndex
398 indexInPage:itemIndex - pageIndex * kItemsPerPage];
401 - (NSUInteger)selectedItemIndex {
402 NSCollectionView* page = [self collectionViewAtPageIndex:visiblePage_];
403 NSUInteger indexOnPage = [[page selectionIndexes] firstIndex];
404 if (indexOnPage == NSNotFound)
407 return indexOnPage + visiblePage_ * kItemsPerPage;
410 - (NSButton*)selectedButton {
411 NSUInteger index = [self selectedItemIndex];
412 if (index == NSNotFound)
415 return [[self itemAtIndex:index] button];
418 - (NSScrollView*)gridScrollView {
419 return base::mac::ObjCCastStrict<NSScrollView>([self view]);
422 - (NSView*)pagesContainerView {
423 return [[self gridScrollView] documentView];
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;
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)];
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]];
449 [[self pagesContainerView] setSubviews:pages_];
450 NSSize pagesSize = NSMakeSize(kViewWidth * targetPages, kViewHeight);
451 [[self pagesContainerView] setFrameSize:pagesSize];
452 [paginationObserver_ totalPagesChanged];
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
463 - (void)updatePageContent:(size_t)pageIndex
464 resetModel:(BOOL)resetModel {
465 NSCollectionView* pageView = [self collectionViewAtPageIndex:pageIndex];
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];
476 NSRange inPageRange = NSIntersectionRange(
477 NSMakeRange(pageIndex * kItemsPerPage, kItemsPerPage),
478 NSMakeRange(0, [items_ count]));
479 NSArray* pageContent = [items_ subarrayWithRange:inPageRange];
480 [pageView setContent:pageContent];
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])];
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
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.
508 if (fromPageIndex > toPageIndex)
509 std::swap(fromPageIndex, toPageIndex);
511 for (size_t i = fromPageIndex; i <= toPageIndex; ++i) {
512 [self updatePageContent:i
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)
524 model_->apps()->RemoveObserver(bridge_.get());
525 model_->apps()->Move(itemIndex, modelIndex);
526 model_->apps()->AddObserver(bridge_.get());
529 - (AppsCollectionViewDragManager*)dragManager {
533 - (size_t)scheduledScrollPage {
534 return scheduledScrollPage_;
537 - (void)listItemsAdded:(size_t)start
538 count:(size_t)count {
539 // Cancel any drag, to ensure the model stays consistent.
540 [dragManager_ cancelDrag];
542 for (size_t i = start; i < start + count; ++i) {
543 app_list::AppListItemModel* itemModel = model_->apps()->GetItemAt(i);
544 [items_ insertObject:[NSValue valueWithPointer:itemModel]
548 [self updatePages:start];
550 for (size_t i = start; i < start + count; ++i)
551 [[self itemAtIndex:i] onInitialModelBuilt];
554 - (void)listItemsRemoved:(size_t)start
555 count:(size_t)count {
556 [dragManager_ cancelDrag];
558 // Clear the models explicitly to avoid surprises from autorelease.
559 for (size_t i = start; i < start + count; ++i)
560 [[self itemAtIndex:i] setModel:NULL];
562 [items_ removeObjectsInRange:NSMakeRange(start, count)];
563 [self updatePages:start];
566 - (void)listItemMovedFromIndex:(size_t)fromIndex
567 toModelIndex:(size_t)toIndex {
568 [dragManager_ cancelDrag];
569 [self moveItemInView:fromIndex
570 toItemIndex:toIndex];
573 - (CGFloat)visiblePortionOfPage:(int)page {
574 CGFloat scrollOffsetOfPage =
575 NSMinX([[[self gridScrollView] contentView] bounds]) / kViewWidth - page;
576 if (scrollOffsetOfPage <= -1.0 || scrollOffsetOfPage >= 1.0)
579 if (scrollOffsetOfPage <= 0.0)
580 return scrollOffsetOfPage + 1.0;
582 return -1.0 + scrollOffsetOfPage;
585 - (void)onPagerClicked:(AppListPagerView*)sender {
586 int selectedSegment = [sender selectedSegment];
587 if (selectedSegment < 0)
588 return; // No selection.
590 int pageIndex = [[sender cell] tagForSegment:selectedSegment];
592 [self scrollToPage:pageIndex];
595 - (BOOL)moveSelectionByDelta:(int)indexDelta {
599 NSUInteger oldIndex = [self selectedItemIndex];
601 // If nothing is currently selected, select the first item on the page.
602 if (oldIndex == NSNotFound) {
603 [self selectItemAtIndex:visiblePage_ * kItemsPerPage];
607 // Can't select a negative index.
608 if (indexDelta < 0 && static_cast<NSUInteger>(-indexDelta) > oldIndex)
611 // Can't select an index greater or equal to the number of items.
612 if (oldIndex + indexDelta >= [items_ count]) {
613 if (visiblePage_ == [pages_ count] - 1)
616 // If we're not on the last page, then select the last item.
617 [self selectItemAtIndex:[items_ count] - 1];
621 [self selectItemAtIndex:oldIndex + indexDelta];
625 - (void)selectItemAtIndex:(NSUInteger)index {
626 if (index >= [items_ count])
629 if (index / kItemsPerPage != visiblePage_)
630 [self scrollToPage:index / kItemsPerPage];
632 [[self itemAtIndex:index] setSelected:YES];
635 - (BOOL)handleCommandBySelector:(SEL)command {
636 if (command == @selector(insertNewline:) ||
637 command == @selector(insertLineBreak:)) {
638 [self activateSelection];
642 NSUInteger oldIndex = [self selectedItemIndex];
643 // If nothing is currently selected, select the first item on the page.
644 if (oldIndex == NSNotFound) {
645 [self selectItemAtIndex:visiblePage_ * kItemsPerPage];
649 if (command == @selector(moveLeft:)) {
650 return oldIndex % kFixedColumns == 0 ?
651 [self moveSelectionByDelta:-kItemsPerPage + kFixedColumns - 1] :
652 [self moveSelectionByDelta:-1];
655 if (command == @selector(moveRight:)) {
656 return oldIndex % kFixedColumns == kFixedColumns - 1 ?
657 [self moveSelectionByDelta:+kItemsPerPage - kFixedColumns + 1] :
658 [self moveSelectionByDelta:1];
661 if (command == @selector(moveUp:)) {
662 return oldIndex / kFixedColumns % kFixedRows == 0 ?
663 NO : [self moveSelectionByDelta:-kFixedColumns];
666 if (command == @selector(moveDown:)) {
667 return oldIndex / kFixedColumns % kFixedRows == kFixedRows - 1 ?
668 NO : [self moveSelectionByDelta:kFixedColumns];
671 if (command == @selector(pageUp:) ||
672 command == @selector(scrollPageUp:))
673 return [self moveSelectionByDelta:-kItemsPerPage];
675 if (command == @selector(pageDown:) ||
676 command == @selector(scrollPageDown:))
677 return [self moveSelectionByDelta:kItemsPerPage];