1 // Copyright 2012 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/base/ios/cru_context_menu_controller.h"
9 #include "base/ios/ios_util.h"
10 #include "base/logging.h"
11 #import "base/mac/scoped_nsobject.h"
12 #include "base/strings/sys_string_conversions.h"
13 #include "ui/base/device_form_factor.h"
14 #import "ui/base/ios/cru_context_menu_holder.h"
15 #include "ui/base/l10n/l10n_util.h"
16 #include "ui/gfx/font_list.h"
17 #import "ui/gfx/ios/NSString+CrStringDrawing.h"
18 #include "ui/gfx/text_elider.h"
19 #include "ui/strings/grit/ui_strings.h"
21 @implementation CRUContextMenuController {
22 // Holds all the titles and actions for the menu.
23 base::scoped_nsobject<CRUContextMenuHolder> menuHolder_;
24 // The action sheet controller used to display the UI.
25 base::scoped_nsobject<UIActionSheet> sheet_;
26 // Whether the context menu is visible.
31 // Clean up and reset for the next time.
33 // iOS 8 fires multiple callbacks when a button is clicked; one round for the
34 // button that's clicked and a second round with |buttonIndex| equivalent to
35 // tapping outside the context menu. Here the sheet's delegate is reset so
36 // that only the first round of callbacks is processed.
37 // Note that iOS 8 needs the |sheet_| to stay alive so it's not reset until
38 // this CRUContextMenuController is dealloc'd.
39 [sheet_ setDelegate:nil];
46 // Context menu must be dismissed explicitly if it is still visible at this
48 NSUInteger cancelButtonIndex = [menuHolder_ itemCount];
49 [sheet_ dismissWithClickedButtonIndex:cancelButtonIndex animated:NO];
59 // Called when the action sheet is dismissed in the modal context menu sheet.
60 // There is no way to dismiss the sheet without going through this method. Note
61 // that on iPad this method is called with the index of an nonexistent cancel
62 // button when the user taps outside the sheet.
63 - (void)actionSheet:(UIActionSheet*)actionSheet
64 didDismissWithButtonIndex:(NSInteger)buttonIndex {
65 // On iOS 8, if the user taps an item in the context menu, then taps outside
66 // the context menu, the |buttonIndex| passed into this method may be
67 // different from the |buttonIndex| passed into
68 // |actionsheet:willDismissWithButtonIndex:|. See crbug.com/411894.
69 NSUInteger buttonIndexU = buttonIndex;
70 // Assumes "cancel" button is last in order.
71 if (buttonIndexU < [menuHolder_ itemCount])
72 [menuHolder_ performActionAtIndex:buttonIndexU];
76 // Called when the user chooses a button in the modal context menu sheet. Note
77 // that on iPad this method is called with the index of an nonexistent cancel
78 // button when the user taps outside the sheet.
79 - (void)actionSheet:(UIActionSheet*)actionSheet
80 clickedButtonAtIndex:(NSInteger)buttonIndex {
81 // Some use cases (e.g. opening a new tab on handset) should not wait for the
82 // action sheet to animate away before executing the action.
83 if ([menuHolder_ shouldDismissImmediatelyOnClickedAtIndex:buttonIndex]) {
84 [sheet_ dismissWithClickedButtonIndex:buttonIndex animated:NO];
89 #pragma mark WebContextMenuDelegate methods
91 // Displays a menu using a sheet with the given title.
92 - (void)showWithHolder:(CRUContextMenuHolder*)menuHolder
93 atPoint:(CGPoint)localPoint
94 inView:(UIView*)view {
95 DCHECK([menuHolder itemCount]);
96 menuHolder_.reset([menuHolder retain]);
97 // Check that the view is still visible on screen, otherwise just return and
98 // don't show the context menu.
99 DCHECK([view window] || [view isKindOfClass:[UIWindow class]]);
100 if (![view window] && ![view isKindOfClass:[UIWindow class]])
102 CGSize spaceAvailableForTitle =
103 [CRUContextMenuController
104 availableSpaceForTitleInActionSheetWithMenu:menuHolder_
107 NSString* title = menuHolder.menuTitle;
109 // Show at least one line of text, even if that means the UIActionSheet's
110 // items will need to scroll.
111 const CGFloat kMinimumVerticalSpace = 21;
112 spaceAvailableForTitle.height =
113 std::max(kMinimumVerticalSpace, spaceAvailableForTitle.height);
114 title = [title cr_stringByElidingToFitSize:spaceAvailableForTitle];
118 [[UIActionSheet alloc] initWithTitle:title
120 cancelButtonTitle:nil
121 destructiveButtonTitle:nil
122 otherButtonTitles:nil]);
123 // Add the labels, in order, to the sheet.
124 for (NSString* label in [menuHolder_ itemTitles]) {
125 [sheet_ addButtonWithTitle:label];
127 // Cancel button goes last, to match other browsers.
128 [sheet_ addButtonWithTitle:l10n_util::GetNSString(IDS_APP_CANCEL)];
129 [sheet_ setCancelButtonIndex:[menuHolder_ itemCount]];
130 [sheet_ showFromRect:CGRectMake(localPoint.x, localPoint.y, 1.0, 1.0)
137 // Returns an approximation of the free space available for the title of an
138 // actionSheet filled with |menu| shown in |view| at |point|.
140 availableSpaceForTitleInActionSheetWithMenu:(CRUContextMenuHolder*)menu
141 atPoint:(CGPoint)point
142 inView:(UIView*)view {
143 BOOL isIpad = ui::GetDeviceFormFactor() == ui::DEVICE_FORM_FACTOR_TABLET;
144 if (base::ios::IsRunningOnIOS8OrLater()) {
145 // On iOS8 presenting and dismissing a dummy UIActionSheet does not work
146 // (http://crbug.com/392245 and rdar://17677745).
147 // As a workaround we return an estimation of the space available depending
148 // on the device's type.
149 const CGFloat kAvailableWidth = 320;
150 const CGFloat kAvailableHeightTablet = 200;
151 const CGFloat kAvailableHeightPhone = 100;
153 return CGSizeMake(kAvailableWidth, kAvailableHeightTablet);
155 return CGSizeMake(kAvailableWidth, kAvailableHeightPhone);
157 // Creates a dummy UIActionSheet.
158 base::scoped_nsobject<UIActionSheet> dummyActionSheet(
159 [[UIActionSheet alloc] initWithTitle:nil
161 cancelButtonTitle:nil
162 destructiveButtonTitle:nil
163 otherButtonTitles:nil]);
164 for (NSString* label in [menu itemTitles]) {
165 [dummyActionSheet addButtonWithTitle:label];
167 [dummyActionSheet addButtonWithTitle:
168 l10n_util::GetNSString(IDS_APP_CANCEL)];
169 // Temporarily adds the dummy UIActionSheet to |view|.
170 [dummyActionSheet showFromRect:CGRectMake(point.x, point.y, 1.0, 1.0)
173 // On iPad the actionsheet is positioned under or over |point| (as opposed
174 // to next to it) when the user clicks within approximately 200 points of
175 // respectively the top or bottom edge. This reduces the amount of vertical
176 // space available for the title, hence the large padding on ipad.
177 const int kPaddingiPad = 200;
178 const int kPaddingiPhone = 20;
179 CGFloat padding = isIpad ? kPaddingiPad : kPaddingiPhone;
180 // A title uses the full width of the actionsheet and all the vertical
181 // space on the screen.
182 CGSize availableSpaceForTitle =
183 CGSizeMake([dummyActionSheet frame].size.width,
184 [CRUContextMenuController screenHeight] -
185 [dummyActionSheet frame].size.height -
187 [dummyActionSheet dismissWithClickedButtonIndex:0 animated:NO];
188 return availableSpaceForTitle;
192 // Returns the screen's height in pixels.
193 + (int)screenHeight {
194 DCHECK(!base::ios::IsRunningOnIOS8OrLater());
195 switch ([[UIApplication sharedApplication] statusBarOrientation]) {
196 case UIInterfaceOrientationLandscapeLeft:
197 case UIInterfaceOrientationLandscapeRight:
198 return [[UIScreen mainScreen] applicationFrame].size.width;
199 case UIInterfaceOrientationPortraitUpsideDown:
200 case UIInterfaceOrientationPortrait:
201 case UIInterfaceOrientationUnknown:
202 return [[UIScreen mainScreen] applicationFrame].size.height;
208 @implementation CRUContextMenuController (UsedForTesting)
209 - (UIActionSheet*)sheet {