OLD | NEW |
---|---|
1 // Copyright 2012 The Chromium Authors. All rights reserved. | 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 | 2 // Use of this source code is governed by a BSD-style license that can be |
3 // found in the LICENSE file. | 3 // found in the LICENSE file. |
4 | 4 |
5 #import "ui/base/ios/cru_context_menu_controller.h" | 5 #import "ui/base/ios/cru_context_menu_controller.h" |
6 | 6 |
7 #include <algorithm> | 7 #include <algorithm> |
8 | 8 |
9 #include "base/ios/ios_util.h" | 9 #include "base/ios/ios_util.h" |
10 #include "base/ios/weak_nsobject.h" | |
10 #include "base/logging.h" | 11 #include "base/logging.h" |
11 #import "base/mac/scoped_nsobject.h" | 12 #import "base/mac/scoped_nsobject.h" |
12 #include "base/strings/sys_string_conversions.h" | |
13 #include "ui/base/device_form_factor.h" | 13 #include "ui/base/device_form_factor.h" |
14 #import "ui/base/ios/cru_context_menu_holder.h" | 14 #import "ui/base/ios/cru_context_menu_holder.h" |
15 #include "ui/base/l10n/l10n_util.h" | 15 #include "ui/base/l10n/l10n_util.h" |
16 #include "ui/gfx/font_list.h" | |
17 #import "ui/gfx/ios/NSString+CrStringDrawing.h" | 16 #import "ui/gfx/ios/NSString+CrStringDrawing.h" |
18 #include "ui/gfx/text_elider.h" | |
19 #include "ui/strings/grit/ui_strings.h" | 17 #include "ui/strings/grit/ui_strings.h" |
20 | 18 |
19 namespace { | |
20 | |
21 // Returns the screen's height in points. | |
22 CGFloat GetScreenHeight() { | |
23 DCHECK(!base::ios::IsRunningOnIOS8OrLater()); | |
24 switch ([[UIApplication sharedApplication] statusBarOrientation]) { | |
25 case UIInterfaceOrientationLandscapeLeft: | |
26 case UIInterfaceOrientationLandscapeRight: | |
27 return CGRectGetWidth([[UIScreen mainScreen] applicationFrame]); | |
28 case UIInterfaceOrientationPortraitUpsideDown: | |
29 case UIInterfaceOrientationPortrait: | |
30 case UIInterfaceOrientationUnknown: | |
31 return CGRectGetHeight([[UIScreen mainScreen] applicationFrame]); | |
32 } | |
33 } | |
34 | |
35 } // namespace | |
36 | |
37 // Abstracts system implementation of popovers and action sheets. | |
38 @protocol CRUContextMenuControllerImpl<NSObject> | |
39 | |
40 // Whether the context menu is visible. | |
41 @property(nonatomic, readonly, getter=isVisible) BOOL visible; | |
42 | |
43 // Displays a context menu. | |
44 - (void)showWithHolder:(CRUContextMenuHolder*)menuHolder | |
45 atPoint:(CGPoint)localPoint | |
46 inView:(UIView*)view; | |
47 @end | |
48 | |
49 // Backs up CRUContextMenuController on iOS 7 by using UIActionSheet. | |
50 @interface CRUActionSheetController | |
51 : NSObject<CRUContextMenuControllerImpl, UIActionSheetDelegate> { | |
52 // The action sheet used to display the UI. | |
53 base::scoped_nsobject<UIActionSheet> _sheet; | |
54 // Holds all the titles and actions for the menu. | |
55 base::scoped_nsobject<CRUContextMenuHolder> _menuHolder; | |
56 } | |
57 @end | |
58 | |
59 // Backs up CRUContextMenuController on iOS 8 and higher by using | |
60 // UIAlertController. | |
61 @interface CRUAlertController : NSObject<CRUContextMenuControllerImpl> | |
62 // Redefinied to readwrite. | |
sdefresne
2015/04/25 11:08:17
nit: s/Redefinied/Redefined/
Eugene But (OOO till 7-30)
2015/04/27 16:42:58
Done.
| |
63 @property(nonatomic, readwrite, getter=isVisible) BOOL visible; | |
64 @end | |
65 | |
66 // Displays a context menu. Implements Bridge pattern. | |
21 @implementation CRUContextMenuController { | 67 @implementation CRUContextMenuController { |
22 // Holds all the titles and actions for the menu. | 68 // Implementation specific for iOS version. |
23 base::scoped_nsobject<CRUContextMenuHolder> menuHolder_; | 69 base::scoped_nsprotocol<id<CRUContextMenuControllerImpl>> _impl; |
24 // The action sheet controller used to display the UI. | 70 } |
25 base::scoped_nsobject<UIActionSheet> sheet_; | 71 |
26 // Whether the context menu is visible. | 72 - (BOOL)isVisible { |
27 BOOL visible_; | 73 return [_impl isVisible]; |
28 } | 74 } |
29 | 75 |
30 | 76 - (instancetype)init { |
31 // Clean up and reset for the next time. | 77 self = [super init]; |
32 - (void)cleanup { | 78 if (self) { |
33 // iOS 8 fires multiple callbacks when a button is clicked; one round for the | 79 if (base::ios::IsRunningOnIOS8OrLater()) { |
34 // button that's clicked and a second round with |buttonIndex| equivalent to | 80 _impl.reset([[CRUAlertController alloc] init]); |
35 // tapping outside the context menu. Here the sheet's delegate is reset so | 81 } else { |
36 // that only the first round of callbacks is processed. | 82 _impl.reset([[CRUActionSheetController alloc] init]); |
37 // Note that iOS 8 needs the |sheet_| to stay alive so it's not reset until | 83 } |
38 // this CRUContextMenuController is dealloc'd. | 84 } |
39 [sheet_ setDelegate:nil]; | 85 return self; |
40 menuHolder_.reset(); | 86 } |
41 visible_ = NO; | 87 |
42 } | 88 - (void)showWithHolder:(CRUContextMenuHolder*)menuHolder |
89 atPoint:(CGPoint)point | |
90 inView:(UIView*)view { | |
91 DCHECK(menuHolder.itemCount); | |
92 // Check that the view is still visible on screen, otherwise just return and | |
93 // don't show the context menu. | |
94 if (![view window] && ![view isKindOfClass:[UIWindow class]]) | |
95 return; | |
96 [_impl showWithHolder:menuHolder atPoint:point inView:view]; | |
97 } | |
98 | |
99 @end | |
100 | |
101 #pragma mark - iOS 7 | |
102 | |
103 @implementation CRUActionSheetController | |
104 @synthesize visible = _visible; | |
43 | 105 |
44 - (void)dealloc { | 106 - (void)dealloc { |
45 if (visible_) { | 107 if (_visible) { |
46 // Context menu must be dismissed explicitly if it is still visible at this | 108 // Context menu must be dismissed explicitly if it is still visible. |
47 // stage. | 109 NSUInteger cancelButtonIndex = [_menuHolder itemCount]; |
48 NSUInteger cancelButtonIndex = [menuHolder_ itemCount]; | 110 [_sheet dismissWithClickedButtonIndex:cancelButtonIndex animated:NO]; |
49 [sheet_ dismissWithClickedButtonIndex:cancelButtonIndex animated:NO]; | 111 } |
50 } | |
51 sheet_.reset(); | |
52 [super dealloc]; | 112 [super dealloc]; |
53 } | 113 } |
54 | 114 |
55 - (BOOL)isVisible { | 115 - (void)showWithHolder:(CRUContextMenuHolder*)menuHolder |
56 return visible_; | 116 atPoint:(CGPoint)point |
57 } | 117 inView:(UIView*)view { |
118 // If the content of UIActionSheet does not fit the screen then scrollbars | |
119 // are added to the menu items area. If that's the case, elide the title to | |
120 // avoid having scrollbars for menu items. | |
121 CGSize spaceAvailableForTitle = | |
122 [self sizeForTitleThatFitsMenuWithHolder:menuHolder | |
123 atPoint:point | |
124 inView:view]; | |
125 NSString* menuTitle = menuHolder.menuTitle; | |
126 if (menuTitle) { | |
127 // Show at least one line of text, even if that means the action sheet's | |
128 // items will need to scroll. | |
129 const CGFloat kMinimumVerticalSpace = 21; | |
130 spaceAvailableForTitle.height = | |
131 std::max(kMinimumVerticalSpace, spaceAvailableForTitle.height); | |
132 menuTitle = [menuTitle cr_stringByElidingToFitSize:spaceAvailableForTitle]; | |
133 } | |
134 | |
135 // Present UIActionSheet. | |
136 _sheet.reset( | |
137 [self newActionSheetWithHolder:menuHolder title:menuTitle delegate:self]); | |
138 [_sheet setCancelButtonIndex:menuHolder.itemCount]; | |
139 [_sheet showFromRect:CGRectMake(point.x, point.y, 1.0, 1.0) | |
140 inView:view | |
141 animated:YES]; | |
142 | |
143 _menuHolder.reset([menuHolder retain]); | |
144 _visible = YES; | |
145 } | |
146 | |
147 #pragma mark Implementation | |
148 | |
149 // Returns an approximation of the free space available for the title of an | |
150 // actionSheet filled with |menu| shown in |view| at |point|. | |
151 - (CGSize)sizeForTitleThatFitsMenuWithHolder:(CRUContextMenuHolder*)menuHolder | |
152 atPoint:(CGPoint)point | |
153 inView:(UIView*)view { | |
154 // Create a dummy UIActionSheet. | |
155 base::scoped_nsobject<UIActionSheet> dummySheet( | |
156 [self newActionSheetWithHolder:menuHolder title:nil delegate:nil]); | |
157 // Temporarily add the dummy UIActionSheet to |view|. | |
158 [dummySheet showFromRect:CGRectMake(point.x, point.y, 1.0, 1.0) | |
159 inView:view | |
160 animated:NO]; | |
161 // On iPad the actionsheet is positioned under or over |point| (as opposed | |
162 // to next to it) when the user clicks within approximately 200 points of | |
163 // respectively the top or bottom edge. This reduces the amount of vertical | |
164 // space available for the title, hence the large padding on ipad. | |
165 const CGFloat kPaddingiPad = 200; | |
166 const CGFloat kPaddingiPhone = 20; | |
167 BOOL isIPad = ui::GetDeviceFormFactor() == ui::DEVICE_FORM_FACTOR_TABLET; | |
168 const CGFloat padding = isIPad ? kPaddingiPad : kPaddingiPhone; | |
169 // A title uses the full width of the actionsheet and all the vertical | |
170 // space on the screen. | |
171 CGSize result = CGSizeMake( | |
172 CGRectGetWidth([dummySheet frame]), | |
173 GetScreenHeight() - CGRectGetHeight([dummySheet frame]) - padding); | |
174 [dummySheet dismissWithClickedButtonIndex:0 animated:NO]; | |
175 return result; | |
176 } | |
177 | |
178 // Returns an UIActionSheet. Callers responsible for releasing returned object. | |
179 - (UIActionSheet*)newActionSheetWithHolder:(CRUContextMenuHolder*)menuHolder | |
180 title:(NSString*)title | |
181 delegate:(id<UIActionSheetDelegate>)delegate { | |
182 UIActionSheet* sheet = [[UIActionSheet alloc] initWithTitle:title | |
183 delegate:delegate | |
184 cancelButtonTitle:nil | |
185 destructiveButtonTitle:nil | |
186 otherButtonTitles:nil]; | |
187 | |
188 for (NSString* itemTitle in menuHolder.itemTitles) { | |
189 [sheet addButtonWithTitle:itemTitle]; | |
190 } | |
191 [sheet addButtonWithTitle:l10n_util::GetNSString(IDS_APP_CANCEL)]; | |
192 return sheet; | |
193 } | |
194 | |
195 #pragma mark UIActionSheetDelegate | |
58 | 196 |
59 // Called when the action sheet is dismissed in the modal context menu sheet. | 197 // 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 | 198 // 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 | 199 // that on iPad this method is called with the index of an nonexistent cancel |
62 // button when the user taps outside the sheet. | 200 // button when the user taps outside the sheet. |
63 - (void)actionSheet:(UIActionSheet*)actionSheet | 201 - (void)actionSheet:(UIActionSheet*)actionSheet |
64 didDismissWithButtonIndex:(NSInteger)buttonIndex { | 202 didDismissWithButtonIndex:(NSInteger)buttonIndex { |
65 // On iOS 8, if the user taps an item in the context menu, then taps outside | 203 NSUInteger unsignedButtonIndex = buttonIndex; |
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. | 204 // Assumes "cancel" button is last in order. |
71 if (buttonIndexU < [menuHolder_ itemCount]) | 205 if (unsignedButtonIndex < [_menuHolder itemCount]) |
72 [menuHolder_ performActionAtIndex:buttonIndexU]; | 206 [_menuHolder performActionAtIndex:unsignedButtonIndex]; |
73 [self cleanup]; | 207 _menuHolder.reset(); |
208 _visible = NO; | |
74 } | 209 } |
75 | 210 |
76 // Called when the user chooses a button in the modal context menu sheet. Note | 211 // 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 | 212 // that on iPad this method is called with the index of an nonexistent cancel |
78 // button when the user taps outside the sheet. | 213 // button when the user taps outside the sheet. |
79 - (void)actionSheet:(UIActionSheet*)actionSheet | 214 - (void)actionSheet:(UIActionSheet*)actionSheet |
80 clickedButtonAtIndex:(NSInteger)buttonIndex { | 215 clickedButtonAtIndex:(NSInteger)buttonIndex { |
81 // Some use cases (e.g. opening a new tab on handset) should not wait for the | 216 // 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. | 217 // action sheet to animate away before executing the action. |
83 if ([menuHolder_ shouldDismissImmediatelyOnClickedAtIndex:buttonIndex]) { | 218 if ([_menuHolder shouldDismissImmediatelyOnClickedAtIndex:buttonIndex]) { |
84 [sheet_ dismissWithClickedButtonIndex:buttonIndex animated:NO]; | 219 [_sheet dismissWithClickedButtonIndex:buttonIndex animated:NO]; |
85 } | 220 } |
86 } | 221 } |
87 | 222 |
88 #pragma mark - | 223 @end |
89 #pragma mark WebContextMenuDelegate methods | 224 |
90 | 225 #pragma mark - iOS8 and higher |
91 // Displays a menu using a sheet with the given title. | 226 |
92 - (void)showWithHolder:(CRUContextMenuHolder*)menuHolder | 227 @implementation CRUAlertController |
93 atPoint:(CGPoint)localPoint | 228 @synthesize visible = _visible; |
229 | |
230 - (CGSize)sizeForTitleThatFitsMenuWithHolder:(CRUContextMenuHolder*)menuHolder | |
231 atPoint:(CGPoint)point | |
232 inView:(UIView*)view { | |
233 // On presenting and dismissing a dummy UIAlertController flushes a screen. | |
sdefresne
2015/04/25 11:08:16
nit: // Presenting and dismissing a dummy ...
Eugene But (OOO till 7-30)
2015/04/27 16:42:58
Done.
| |
234 // As a workaround return an estimation of the space available depending | |
235 // on the device's type. | |
236 const CGFloat kAvailableWidth = 320; | |
237 const CGFloat kAvailableHeightTablet = 200; | |
238 const CGFloat kAvailableHeightPhone = 100; | |
239 if (ui::GetDeviceFormFactor() == ui::DEVICE_FORM_FACTOR_TABLET) { | |
240 return CGSizeMake(kAvailableWidth, kAvailableHeightTablet); | |
241 } | |
242 return CGSizeMake(kAvailableWidth, kAvailableHeightPhone); | |
243 } | |
244 | |
245 - (void)showWithHolder:(CRUContextMenuHolder*)menuHolder | |
246 atPoint:(CGPoint)point | |
94 inView:(UIView*)view { | 247 inView:(UIView*)view { |
95 DCHECK([menuHolder itemCount]); | 248 UIAlertController* alert = [UIAlertController |
96 menuHolder_.reset([menuHolder retain]); | 249 alertControllerWithTitle:menuHolder.menuTitle |
97 // Check that the view is still visible on screen, otherwise just return and | 250 message:nil |
98 // don't show the context menu. | 251 preferredStyle:UIAlertControllerStyleActionSheet]; |
99 DCHECK([view window] || [view isKindOfClass:[UIWindow class]]); | 252 alert.popoverPresentationController.sourceView = view; |
100 if (![view window] && ![view isKindOfClass:[UIWindow class]]) | 253 alert.popoverPresentationController.sourceRect = |
101 return; | 254 CGRectMake(point.x, point.y, 1.0, 1.0); |
102 CGSize spaceAvailableForTitle = | 255 |
103 [CRUContextMenuController | 256 // Add the actions. |
104 availableSpaceForTitleInActionSheetWithMenu:menuHolder_ | 257 base::WeakNSObject<CRUAlertController> weakSelf(self); |
105 atPoint:localPoint | 258 [menuHolder.itemTitles enumerateObjectsUsingBlock:^(NSString* itemTitle, |
106 inView:view]; | 259 NSUInteger itemIndex, |
107 NSString* title = menuHolder.menuTitle; | 260 BOOL*) { |
108 if (title) { | 261 void (^actionHandler)(UIAlertAction*) = ^(UIAlertAction* action) { |
109 // Show at least one line of text, even if that means the UIActionSheet's | 262 [menuHolder performActionAtIndex:itemIndex]; |
110 // items will need to scroll. | 263 [weakSelf setVisible:NO]; |
111 const CGFloat kMinimumVerticalSpace = 21; | 264 }; |
112 spaceAvailableForTitle.height = | 265 [alert addAction:[UIAlertAction actionWithTitle:itemTitle |
113 std::max(kMinimumVerticalSpace, spaceAvailableForTitle.height); | 266 style:UIAlertActionStyleDefault |
114 title = [title cr_stringByElidingToFitSize:spaceAvailableForTitle]; | 267 handler:actionHandler]]; |
115 } | 268 }]; |
116 // Create the sheet. | 269 |
117 sheet_.reset( | |
118 [[UIActionSheet alloc] initWithTitle:title | |
119 delegate:self | |
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]; | |
126 } | |
127 // Cancel button goes last, to match other browsers. | 270 // Cancel button goes last, to match other browsers. |
128 [sheet_ addButtonWithTitle:l10n_util::GetNSString(IDS_APP_CANCEL)]; | 271 UIAlertAction* cancel_action = |
129 [sheet_ setCancelButtonIndex:[menuHolder_ itemCount]]; | 272 [UIAlertAction actionWithTitle:l10n_util::GetNSString(IDS_APP_CANCEL) |
130 [sheet_ showFromRect:CGRectMake(localPoint.x, localPoint.y, 1.0, 1.0) | 273 style:UIAlertActionStyleCancel |
131 inView:view | 274 handler:nil]; |
132 animated:YES]; | 275 [alert addAction:cancel_action]; |
133 | 276 |
134 visible_ = YES; | 277 // Present sheet/popover using controller that is added to view hierarchy. |
135 } | 278 UIViewController* topController = view.window.rootViewController; |
136 | 279 while (topController.presentedViewController) |
137 // Returns an approximation of the free space available for the title of an | 280 topController = topController.presentedViewController; |
138 // actionSheet filled with |menu| shown in |view| at |point|. | 281 [topController presentViewController:alert |
139 + (CGSize) | 282 animated:YES |
140 availableSpaceForTitleInActionSheetWithMenu:(CRUContextMenuHolder*)menu | 283 completion:^{ |
141 atPoint:(CGPoint)point | 284 [weakSelf setVisible:YES]; |
142 inView:(UIView*)view { | 285 }]; |
143 BOOL isIpad = ui::GetDeviceFormFactor() == ui::DEVICE_FORM_FACTOR_TABLET; | 286 } |
144 if (base::ios::IsRunningOnIOS8OrLater()) { | 287 |
145 // On iOS8 presenting and dismissing a dummy UIActionSheet does not work | 288 @end |
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; | |
152 if (isIpad) { | |
153 return CGSizeMake(kAvailableWidth, kAvailableHeightTablet); | |
154 } | |
155 return CGSizeMake(kAvailableWidth, kAvailableHeightPhone); | |
156 } else { | |
157 // Creates a dummy UIActionSheet. | |
158 base::scoped_nsobject<UIActionSheet> dummyActionSheet( | |
159 [[UIActionSheet alloc] initWithTitle:nil | |
160 delegate:nil | |
161 cancelButtonTitle:nil | |
162 destructiveButtonTitle:nil | |
163 otherButtonTitles:nil]); | |
164 for (NSString* label in [menu itemTitles]) { | |
165 [dummyActionSheet addButtonWithTitle:label]; | |
166 } | |
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) | |
171 inView:view | |
172 animated:NO]; | |
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 - | |
186 padding); | |
187 [dummyActionSheet dismissWithClickedButtonIndex:0 animated:NO]; | |
188 return availableSpaceForTitle; | |
189 } | |
190 } | |
191 | |
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; | |
203 } | |
204 } | |
205 | |
206 @end | |
207 | |
208 @implementation CRUContextMenuController (UsedForTesting) | |
209 - (UIActionSheet*)sheet { | |
210 return sheet_.get(); | |
211 } | |
212 @end | |
OLD | NEW |