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.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::AppListItem*)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, AppListItem* item) OVERRIDE { 102 [parent_ listItemAdded:index 103 item:item]; 104 } 105 virtual void OnListItemRemoved(size_t index, AppListItem* item) OVERRIDE { 106 [parent_ listItemRemoved:index]; 107 } 108 virtual void OnListItemMoved(size_t from_index, 109 size_t to_index, 110 AppListItem* item) OVERRIDE { 111 [parent_ listItemMovedFromIndex:from_index 112 toModelIndex:to_index]; 113 } 114 115 AppsGridController* parent_; // Weak, owns us. 116 117 DISALLOW_COPY_AND_ASSIGN(AppsGridDelegateBridge); 118}; 119 120} // namespace app_list 121 122@interface PageContainerView : NSView; 123@end 124 125// The container view needs to flip coordinates so that it is laid out 126// correctly whether or not there is a horizontal scrollbar. 127@implementation PageContainerView 128 129- (BOOL)isFlipped { 130 return YES; 131} 132 133@end 134 135@implementation AppsGridController 136 137+ (void)setScrollAnimationDuration:(NSTimeInterval)duration { 138 g_scroll_duration = duration; 139} 140 141+ (CGFloat)scrollerPadding { 142 return kScrollerPadding; 143} 144 145@synthesize paginationObserver = paginationObserver_; 146 147- (id)init { 148 if ((self = [super init])) { 149 bridge_.reset(new app_list::AppsGridDelegateBridge(self)); 150 NSSize cellSize = NSMakeSize(kPreferredTileWidth, kPreferredTileHeight); 151 dragManager_.reset( 152 [[AppsCollectionViewDragManager alloc] initWithCellSize:cellSize 153 rows:kFixedRows 154 columns:kFixedColumns 155 gridController:self]); 156 pages_.reset([[NSMutableArray alloc] init]); 157 items_.reset([[NSMutableArray alloc] init]); 158 [self loadAndSetView]; 159 [self updatePages:0]; 160 } 161 return self; 162} 163 164- (void)dealloc { 165 [[NSNotificationCenter defaultCenter] removeObserver:self]; 166 [super dealloc]; 167} 168 169- (NSCollectionView*)collectionViewAtPageIndex:(size_t)pageIndex { 170 return [pages_ objectAtIndex:pageIndex]; 171} 172 173- (size_t)pageIndexForCollectionView:(NSCollectionView*)page { 174 for (size_t pageIndex = 0; pageIndex < [pages_ count]; ++pageIndex) { 175 if (page == [self collectionViewAtPageIndex:pageIndex]) 176 return pageIndex; 177 } 178 return NSNotFound; 179} 180 181- (app_list::AppListModel*)model { 182 return delegate_ ? delegate_->GetModel() : NULL; 183} 184 185- (void)setDelegate:(app_list::AppListViewDelegate*)newDelegate { 186 if (delegate_) { 187 app_list::AppListModel* oldModel = delegate_->GetModel(); 188 if (oldModel) 189 oldModel->top_level_item_list()->RemoveObserver(bridge_.get()); 190 } 191 192 // Since the old model may be getting deleted, and the AppKit objects might 193 // be sitting in an NSAutoreleasePool, ensure there are no references to 194 // the model. 195 for (size_t i = 0; i < [items_ count]; ++i) 196 [[self itemAtIndex:i] setModel:NULL]; 197 198 [items_ removeAllObjects]; 199 [self updatePages:0]; 200 [self scrollToPage:0]; 201 202 delegate_ = newDelegate; 203 if (!delegate_) 204 return; 205 206 app_list::AppListModel* newModel = delegate_->GetModel(); 207 if (!newModel) 208 return; 209 210 newModel->top_level_item_list()->AddObserver(bridge_.get()); 211 for (size_t i = 0; i < newModel->top_level_item_list()->item_count(); ++i) { 212 app_list::AppListItem* itemModel = 213 newModel->top_level_item_list()->item_at(i); 214 [items_ insertObject:[NSValue valueWithPointer:itemModel] 215 atIndex:i]; 216 } 217 [self updatePages:0]; 218} 219 220- (size_t)visiblePage { 221 return visiblePage_; 222} 223 224- (void)activateSelection { 225 [[self selectedButton] performClick:self]; 226} 227 228- (size_t)pageCount { 229 return [pages_ count]; 230} 231 232- (size_t)itemCount { 233 return [items_ count]; 234} 235 236- (void)scrollToPage:(size_t)pageIndex { 237 NSClipView* clipView = [[self gridScrollView] contentView]; 238 NSPoint newOrigin = [clipView bounds].origin; 239 240 // Scrolling outside of this range is edge elasticity, which animates 241 // automatically. 242 if ((pageIndex == 0 && (newOrigin.x <= 0)) || 243 (pageIndex + 1 == [self pageCount] && 244 newOrigin.x >= pageIndex * kViewWidth)) { 245 return; 246 } 247 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]]; 252 } 253 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]; 262} 263 264- (void)maybeChangePageForPoint:(NSPoint)locationInWindow { 265 NSPoint pointInView = [[self view] convertPoint:locationInWindow 266 fromView:nil]; 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; 272 else 273 targetPage += targetPage < [pages_ count] - 1 ? 1 : 0; 274 [self scrollToPageWithTimer:targetPage]; 275 return; 276 } 277 278 if (paginationObserver_) { 279 NSInteger segment = 280 [paginationObserver_ pagerSegmentAtLocation:locationInWindow]; 281 if (segment >= 0 && static_cast<size_t>(segment) != targetScrollPage_) { 282 [self scrollToPageWithTimer:segment]; 283 return; 284 } 285 } 286 287 // Otherwise the point may have moved back into the view. 288 [self cancelScrollTimer]; 289} 290 291- (void)cancelScrollTimer { 292 scheduledScrollPage_ = targetScrollPage_; 293 [scrollWhileDraggingTimer_ invalidate]; 294} 295 296- (void)scrollToPageWithTimer:(size_t)targetPage { 297 if (targetPage == targetScrollPage_) { 298 [self cancelScrollTimer]; 299 return; 300 } 301 302 if (targetPage == scheduledScrollPage_) 303 return; 304 305 scheduledScrollPage_ = targetPage; 306 [scrollWhileDraggingTimer_ invalidate]; 307 scrollWhileDraggingTimer_.reset( 308 [[NSTimer scheduledTimerWithTimeInterval:kScrollWhileDraggingDelay 309 target:self 310 selector:@selector(onTimer:) 311 userInfo:nil 312 repeats:NO] retain]); 313} 314 315- (void)onTimer:(NSTimer*)theTimer { 316 if (scheduledScrollPage_ == targetScrollPage_) 317 return; // Already animating scroll. 318 319 [self scrollToPage:scheduledScrollPage_]; 320} 321 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; 329} 330 331- (size_t)nearestPageIndex { 332 return lround( 333 NSMinX([[[self gridScrollView] contentView] bounds]) / kViewWidth); 334} 335 336- (void)userScrolling:(BOOL)isScrolling { 337 if (isScrolling) { 338 if (animatingScroll_) 339 [self cancelScrollAnimation]; 340 } else { 341 [self scrollToPage:[self nearestPageIndex]]; 342 } 343} 344 345- (void)loadAndSetView { 346 base::scoped_nsobject<PageContainerView> pagesContainer( 347 [[PageContainerView alloc] initWithFrame:NSZeroRect]); 348 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]; 359 360 [[NSNotificationCenter defaultCenter] 361 addObserver:self 362 selector:@selector(boundsDidChange:) 363 name:NSViewBoundsDidChangeNotification 364 object:[scrollView contentView]]; 365 366 [self setView:scrollView]; 367} 368 369- (void)boundsDidChange:(NSNotification*)notification { 370 size_t newPage = [self nearestPageIndex]; 371 if (newPage == visiblePage_) { 372 [paginationObserver_ pageVisibilityChanged]; 373 return; 374 } 375 376 visiblePage_ = newPage; 377 [paginationObserver_ selectedPageChanged:newPage]; 378 [paginationObserver_ pageVisibilityChanged]; 379} 380 381- (void)onItemClicked:(id)sender { 382 for (size_t i = 0; i < [items_ count]; ++i) { 383 AppsGridViewItem* gridItem = [self itemAtIndex:i]; 384 if ([[gridItem button] isEqual:sender]) 385 [gridItem model]->Activate(0); 386 } 387} 388 389- (AppsGridViewItem*)itemAtPageIndex:(size_t)pageIndex 390 indexInPage:(size_t)indexInPage { 391 return base::mac::ObjCCastStrict<AppsGridViewItem>( 392 [[self collectionViewAtPageIndex:pageIndex] itemAtIndex:indexInPage]); 393} 394 395- (AppsGridViewItem*)itemAtIndex:(size_t)itemIndex { 396 const size_t pageIndex = itemIndex / kItemsPerPage; 397 return [self itemAtPageIndex:pageIndex 398 indexInPage:itemIndex - pageIndex * kItemsPerPage]; 399} 400 401- (NSUInteger)selectedItemIndex { 402 NSCollectionView* page = [self collectionViewAtPageIndex:visiblePage_]; 403 NSUInteger indexOnPage = [[page selectionIndexes] firstIndex]; 404 if (indexOnPage == NSNotFound) 405 return NSNotFound; 406 407 return indexOnPage + visiblePage_ * kItemsPerPage; 408} 409 410- (NSButton*)selectedButton { 411 NSUInteger index = [self selectedItemIndex]; 412 if (index == NSNotFound) 413 return nil; 414 415 return [[self itemAtIndex:index] button]; 416} 417 418- (NSScrollView*)gridScrollView { 419 return base::mac::ObjCCastStrict<NSScrollView>([self view]); 420} 421 422- (NSView*)pagesContainerView { 423 return [[self gridScrollView] documentView]; 424} 425 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; 431 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)]; 439 } else { 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]]; 446 } 447 } 448 449 [[self pagesContainerView] setSubviews:pages_]; 450 NSSize pagesSize = NSMakeSize(kViewWidth * targetPages, kViewHeight); 451 [[self pagesContainerView] setFrameSize:pagesSize]; 452 [paginationObserver_ totalPagesChanged]; 453 } 454 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 459 resetModel:YES]; 460 } 461} 462 463- (void)updatePageContent:(size_t)pageIndex 464 resetModel:(BOOL)resetModel { 465 NSCollectionView* pageView = [self collectionViewAtPageIndex:pageIndex]; 466 if (resetModel) { 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* gridItem = base::mac::ObjCCastStrict<AppsGridViewItem>( 471 [pageView itemAtIndex:i]); 472 [gridItem setModel:NULL]; 473 } 474 } 475 476 NSRange inPageRange = NSIntersectionRange( 477 NSMakeRange(pageIndex * kItemsPerPage, kItemsPerPage), 478 NSMakeRange(0, [items_ count])); 479 NSArray* pageContent = [items_ subarrayWithRange:inPageRange]; 480 [pageView setContent:pageContent]; 481 if (!resetModel) 482 return; 483 484 for (size_t i = 0; i < [pageContent count]; ++i) { 485 AppsGridViewItem* gridItem = base::mac::ObjCCastStrict<AppsGridViewItem>( 486 [pageView itemAtIndex:i]); 487 [gridItem setModel:static_cast<app_list::AppListItem*>( 488 [[pageContent objectAtIndex:i] pointerValue])]; 489 } 490} 491 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 498 atIndex:toIndex]; 499 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. 505 return; 506 } 507 508 if (fromPageIndex > toPageIndex) 509 std::swap(fromPageIndex, toPageIndex); 510 511 for (size_t i = fromPageIndex; i <= toPageIndex; ++i) { 512 [self updatePageContent:i 513 resetModel:YES]; 514 } 515} 516 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) 522 return; 523 524 app_list::AppListItemList* itemList = [self model]->top_level_item_list(); 525 itemList->RemoveObserver(bridge_.get()); 526 itemList->MoveItem(itemIndex, modelIndex); 527 itemList->AddObserver(bridge_.get()); 528} 529 530- (AppsCollectionViewDragManager*)dragManager { 531 return dragManager_; 532} 533 534- (size_t)scheduledScrollPage { 535 return scheduledScrollPage_; 536} 537 538- (void)listItemAdded:(size_t)index 539 item:(app_list::AppListItem*)itemModel { 540 // Cancel any drag, to ensure the model stays consistent. 541 [dragManager_ cancelDrag]; 542 543 [items_ insertObject:[NSValue valueWithPointer:itemModel] 544 atIndex:index]; 545 546 [self updatePages:index]; 547} 548 549- (void)listItemRemoved:(size_t)index { 550 [dragManager_ cancelDrag]; 551 552 // Clear the models explicitly to avoid surprises from autorelease. 553 [[self itemAtIndex:index] setModel:NULL]; 554 555 [items_ removeObjectsInRange:NSMakeRange(index, 1)]; 556 [self updatePages:index]; 557} 558 559- (void)listItemMovedFromIndex:(size_t)fromIndex 560 toModelIndex:(size_t)toIndex { 561 [dragManager_ cancelDrag]; 562 [self moveItemInView:fromIndex 563 toItemIndex:toIndex]; 564} 565 566- (CGFloat)visiblePortionOfPage:(int)page { 567 CGFloat scrollOffsetOfPage = 568 NSMinX([[[self gridScrollView] contentView] bounds]) / kViewWidth - page; 569 if (scrollOffsetOfPage <= -1.0 || scrollOffsetOfPage >= 1.0) 570 return 0.0; 571 572 if (scrollOffsetOfPage <= 0.0) 573 return scrollOffsetOfPage + 1.0; 574 575 return -1.0 + scrollOffsetOfPage; 576} 577 578- (void)onPagerClicked:(AppListPagerView*)sender { 579 int selectedSegment = [sender selectedSegment]; 580 if (selectedSegment < 0) 581 return; // No selection. 582 583 int pageIndex = [[sender cell] tagForSegment:selectedSegment]; 584 if (pageIndex >= 0) 585 [self scrollToPage:pageIndex]; 586} 587 588- (BOOL)moveSelectionByDelta:(int)indexDelta { 589 if (indexDelta == 0) 590 return NO; 591 592 NSUInteger oldIndex = [self selectedItemIndex]; 593 594 // If nothing is currently selected, select the first item on the page. 595 if (oldIndex == NSNotFound) { 596 [self selectItemAtIndex:visiblePage_ * kItemsPerPage]; 597 return YES; 598 } 599 600 // Can't select a negative index. 601 if (indexDelta < 0 && static_cast<NSUInteger>(-indexDelta) > oldIndex) 602 return NO; 603 604 // Can't select an index greater or equal to the number of items. 605 if (oldIndex + indexDelta >= [items_ count]) { 606 if (visiblePage_ == [pages_ count] - 1) 607 return NO; 608 609 // If we're not on the last page, then select the last item. 610 [self selectItemAtIndex:[items_ count] - 1]; 611 return YES; 612 } 613 614 [self selectItemAtIndex:oldIndex + indexDelta]; 615 return YES; 616} 617 618- (void)selectItemAtIndex:(NSUInteger)index { 619 if (index >= [items_ count]) 620 return; 621 622 if (index / kItemsPerPage != visiblePage_) 623 [self scrollToPage:index / kItemsPerPage]; 624 625 [[self itemAtIndex:index] setSelected:YES]; 626} 627 628- (BOOL)handleCommandBySelector:(SEL)command { 629 if (command == @selector(insertNewline:) || 630 command == @selector(insertLineBreak:)) { 631 [self activateSelection]; 632 return YES; 633 } 634 635 NSUInteger oldIndex = [self selectedItemIndex]; 636 // If nothing is currently selected, select the first item on the page. 637 if (oldIndex == NSNotFound) { 638 [self selectItemAtIndex:visiblePage_ * kItemsPerPage]; 639 return YES; 640 } 641 642 if (command == @selector(moveLeft:)) { 643 return oldIndex % kFixedColumns == 0 ? 644 [self moveSelectionByDelta:-kItemsPerPage + kFixedColumns - 1] : 645 [self moveSelectionByDelta:-1]; 646 } 647 648 if (command == @selector(moveRight:)) { 649 return oldIndex % kFixedColumns == kFixedColumns - 1 ? 650 [self moveSelectionByDelta:+kItemsPerPage - kFixedColumns + 1] : 651 [self moveSelectionByDelta:1]; 652 } 653 654 if (command == @selector(moveUp:)) { 655 return oldIndex / kFixedColumns % kFixedRows == 0 ? 656 NO : [self moveSelectionByDelta:-kFixedColumns]; 657 } 658 659 if (command == @selector(moveDown:)) { 660 return oldIndex / kFixedColumns % kFixedRows == kFixedRows - 1 ? 661 NO : [self moveSelectionByDelta:kFixedColumns]; 662 } 663 664 if (command == @selector(pageUp:) || 665 command == @selector(scrollPageUp:)) 666 return [self moveSelectionByDelta:-kItemsPerPage]; 667 668 if (command == @selector(pageDown:) || 669 command == @selector(scrollPageDown:)) 670 return [self moveSelectionByDelta:kItemsPerPage]; 671 672 return NO; 673} 674 675@end 676