作者:zyl910
现在比较流行使用侧开菜单设计。试了不少控件,感觉GHSidebarNav最成熟,尤其对纯代码创建的界面兼容性最好。但若想使Storyboard界面也支持该控件,该怎么做呢。于是我做了一番研究。
系统环境——
Mac OS X Lion 10.7.5
Xcode 4.6.2
一、功能需求
对于实际项目中使用侧开菜单,有以下功能需求——
1. 非启动。程序启动时位于登陆页面,点击“登录”才进入主页。
2. 点击弹出菜单。点击主页中左上角的按钮,打开左侧的菜单列表。
3. 菜单操作。点击左侧菜单列表中(除“注销”之外)的项目,会对内容页面进行切换。但点击“注销”时,会全部退出,回到登录页面。
4. 子页面导航。对于各个内容页面,点击其中的按钮,可以正常的进入下级页面。而且能从下级页面退回到原内容页面。
5. 手势拉出菜单。无论是在内容页面还是下级页面时,从左向右拖曳标题条,可拉出左侧菜单。
6. 切换页面栈。假设原来是下级页面,中途拉出菜单切换到另一内容页面,随后再拉出菜单点击原项时,应该回到原下级页面,而不是顶层的内容页面。
7. 分辨率兼容。全面兼容所有iOS设备(iPhone、iPad……)的横屏、竖屏模式。
从上面的功能需求中可以看到,现在存在 Storyboard界面 与 纯代码界面 之间访问的问题——
1) 登陆界面是在Storyboard中的,登陆时许切换到纯代码的GHSidebarNav界面.
2) GHSidebarNav控件是纯代码界面,需要用它来管理各个内容页面,而这些内容页面是在Storyboard中的.
二、页面切换方法
2.1 切换到纯代码界面
切换到纯代码界面有以下两种办法。
2.1.1 显示模态页面
调用UIViewController的presentModalViewController方法以模态方式显示页面——
// Display another view controller as a modal child. Uses a vertical sheet transition if animated.This method has been replaced by presentViewController:animated:completion: - (void)presentModalViewController:(UIViewController *)modalViewController animated:(BOOL)animated NS_DEPRECATED_IOS(2_0, 6_0);
使用这种方式进行切换时,新页面不会继承之前页面中的控件,而是只显示自身界面。
当需要从模态页面中返回时,可调用dismissModalViewControllerAnimated方法——
// Dismiss the current modal child. Uses a vertical sheet transition if animated. This method has been replaced by dismissViewControllerAnimated:completion: - (void)dismissModalViewControllerAnimated:(BOOL)animated NS_DEPRECATED_IOS(2_0, 6_0);
2.2.2 push到下级页面
当使用导航控制器(UINavigationController)时,可以调用它的pushViewController来转到下级页面——
- (void)pushViewController:(UIViewController *)viewController animated:(BOOL)animated; // Uses a horizontal slide transition. Has no effect if the view controller is already in the stack.
使用这种方式进行切换时,下级页面会继承之前页面中的控件,如顶部的导航条等。
下级页面的导航条的左侧默认会出现返回按钮。如果想手动返回的话,可以调用UINavigationController的popViewControllerAnimated等方法——
- (UIViewController *)popViewControllerAnimated:(BOOL)animated; // Returns the popped controller. - (NSArray *)popToViewController:(UIViewController *)viewController animated:(BOOL)animated; // Pops view controllers until the one specified is on top. Returns the popped controllers. - (NSArray *)popToRootViewControllerAnimated:(BOOL)animated; // Pops until there's only a single view controller left on the stack. Returns the popped controllers.
怎样从页面中获取UINavigationController呢?可以调用UIViewController (UINavigationControllerItem) 的navigationController属性.
@property(nonatomic,readonly,retain) UINavigationController *navigationController; // If this view controller has been pushed onto a navigation controller, return it.
2.1.3 小结
因GHSidebarNav的GHRevealViewController是用做页面管理器,不希望继承之前页面的控件,所以应该调用presentModalViewController以模态方式显示.
内容页面及下级页面一般是采用导航控制器进行连接的,所以应该调用pushViewController。这部分还可以交由Storyboard以图形化方式进行管理.
2.2 切换到Storyboard界面
2.2.1 从Storyboard界面
从Storyboard界面切换到切换到Storyboard界面是很方便的。
最简单的办法是——在按钮上拖曳鼠标右键到新页面,创建连线(Segue)。
当需要在跳转前做一些验证(例如登录按钮)时,就不能使用上一种办法了。
这时得创建一个从ViewController到新页面的Segue,并对该Segue的Identifier进行命名。然后写代码进行切换,调用UIViewController的performSegueWithIdentifier方法进行切换——
- (void)performSegueWithIdentifier:(NSString *)identifier sender:(id)sender NS_AVAILABLE_IOS(5_0);
2.2.1 从纯代码界面
如果想从纯代码界面切换到Storyboard界面,那就有点麻烦了。因为纯代码界面只是一个单纯的Objective-C类,不在Storyboard上,更别说创建一个从纯代码界面切换到Storyboard界面的Segue了。该怎么办呢?
查了一下文档,发现UIStoryboard中有一个instantiateViewControllerWithIdentifier方法,可根据标识符找到该页面的实例——
- (id)instantiateViewControllerWithIdentifier:(NSString *)identifier;
怎样设置标识符呢。来到界面设计器(Interface Builder),选择一个页面的ViewController,将右侧Utilities窗口切换到Identity inspector面板,在“Storyboard ID”文本框中输入标识符,并勾选下面的“Use Storyboard ID”。
调用UIViewController的storyboard属性可以获得其所属的UIStoryboard对象,注意仅对Storyboard上的页面有效——
@property(nonatomic, readonly, retain) UIStoryboard *storyboard NS_AVAILABLE_IOS(5_0);
三、示例详解
3.1 初始
首先,我们在Xcode中创建一个iOS的Single View Application,项目名为“StoryboardSideMenu”,前缀缩写为“SideMenu”。
于是默认会创建以下两个类——
SideMenuAppDelegate : UIResponder
SideMenuViewController : UIViewController
在Finder中将GHSidebarNav控件的GHSidebarNav目录与图片复制到项目目录,然后拖曳它们到xcode的本项目中。
为了简化界面设计,可以使iPhone与iPad共用一套Storyboard。在左侧的project navigator中点击StoryboardSideMenu,打开项目配置。选择“StoryboardSideMenu”TARGETS,找到“iPad Deploymenu Info”中的“Main Storyboard”组合框,将其修改为“MainStoryboard_iPhone”。
3.2 创建IRevealControllerProperty协议用于传递GHRevealViewController对象
对于被GHRevealViewController管理的左侧菜单页面与各个内容页面,经常需要获取其所属的GHRevealViewController对象。
于是我创建了一个IRevealControllerProperty协议,用于传递GHRevealViewController对象,左侧菜单页面与各个内容页面可实现该协议。
/// 具有revealController(侧开菜单控制器)属性的接口. @protocol IRevealControllerProperty <NSObject> /// 侧开菜单控制器. @property (nonatomic,weak) GHRevealViewController* revealController; @end
3.3 在登陆页面中转到GHRevealViewController并绑定好左侧菜单
SideMenuViewController现在被当作登陆页面,在其中放置一个“登陆”按钮。
分解任务能使程序变的更简单,我们可以在“登陆”按钮中创建GHRevealViewController并与左侧菜单页面绑定,然后由左侧菜单页面的viewDidLoad方法来创建各个内容页面。
在Storyboard中增加一个ViewController,用做左侧菜单页面,将类名与StoryboardID均设为MenuListViewController。
然后创建MenuListViewController类,继承自UIViewController。
打开MenuListViewController.h,实现IRevealControllerProperty接口。既——
#import "IRevealControllerProperty.h" /// 菜单页面. @interface MenuListViewController : UIViewController <IRevealControllerProperty>
打开MenuListViewController.m,实现revealController属性。
@implementation MenuListViewController @synthesize revealController;
现在MenuListViewController准备的差不多了,该来实现登陆按钮的代码了。在Storyboard中为SideMenuViewController的“登陆”按钮的TouchUpInside事件绑定到loginButton_TouchUpInside方法。然后打开SideMenuViewController.m,实现代码——
/// 登陆按钮:点击事件. - (IBAction)loginButton_TouchUpInside:(id)sender { // 获取菜单页面. MenuListViewController* menuVc = [self.storyboard instantiateViewControllerWithIdentifier:@"MenuListViewController"]; NSLog(@"instantiateViewControllerWithIdentifier: %@", menuVc); if (nil==menuVc) return; // 直接模态弹出菜单页面(已废弃,仅用于调试). if (NO) { menuVc.modalTransitionStyle = UIModalTransitionStyleCrossDissolve; // 淡入淡出. [self presentModalViewController:menuVc animated:YES]; } // 模态弹出侧开菜单控制器. if (YES) { //UIColor *bgColor = [UIColor colorWithRed:(50.0f/255.0f) green:(57.0f/255.0f) blue:(74.0f/255.0f) alpha:1.0f]; UIColor *bgColor = [UIColor whiteColor]; GHRevealViewController* revealController = [[GHRevealViewController alloc] initWithNibName:nil bundle:nil]; revealController.view.backgroundColor = bgColor; // 绑定. menuVc.revealController = revealController; revealController.sidebarViewController = menuVc; // show. revealController.modalTransitionStyle = UIModalTransitionStyleCrossDissolve; // 淡入淡出. [self presentModalViewController:revealController animated:YES]; } }
上述代码很好理解。
因为SideMenuViewController是Storyboard中的页面,所以可以通过self.storyboard获取UIStoryboard。然后以“MenuListViewController”为参数调用instantiateViewControllerWithIdentifier方法获得菜单页面(MenuListViewController)。
随后创建GHRevealViewController对象,将它的sidebarViewController属性绑定为菜单页面,别忘了给菜单页面的revealController属性赋值。然后再调用presentModalViewController方法,模态弹出侧开菜单控制器。
编译运行,会发现登陆后变为一片黑色。这是因为我们还没有设置contentViewController,还没有内容页面.
3.4 在菜单页面中构造好内容页面
现在开始准备内容页面,打开Storyboard。
因考虑到要支持子页面push,所以应该选择NavigationController。NavigationController会附带一个TableViewController,而我们不需要,于是将TableViewController删掉,换成ViewController用来做主页。
配置NavigationController,将StoryboardID设为HomeNavigationController。
配置主页的界面,并在标题条的的左侧放置一个按钮。将主页的类名设为HomeViewController。
然后创建HomeViewController类,继承自UIViewController。
打开HomeViewController.h,实现IRevealControllerProperty接口。既——
#import "IRevealControllerProperty.h" /// 主页页面. @interface HomeViewController : UIViewController <IRevealControllerProperty>
打开HomeViewController.m,实现revealController属性。
@implementation MenuListViewController @synthesize revealController;
现在HomeViewController准备的差不多了,该来实现显示主页的代码了。
打开MenuListViewController.m,找到viewDidLoad方法,将其修改为——
- (void)viewDidLoad { [super viewDidLoad]; // 设置自身窗口尺寸 self.view.frame = CGRectMake(0.0f, 0.0f, kGHRevealSidebarWidth, CGRectGetHeight(self.view.bounds)); self.view.autoresizingMask = UIViewAutoresizingFlexibleHeight; // 绑定主页为内容视图. if (YES) { UINavigationController* homeNC = [self.storyboard instantiateViewControllerWithIdentifier:@"HomeNavigationController"]; NSLog(@"instantiateViewControllerWithIdentifier: %@", homeNC); [SideMenuUtil addNavigationGesture:homeNC revealController:revealController]; //homeNC.revealController = revealController; [SideMenuUtil setRevealControllerProperty:homeNC revealController:revealController]; revealController.contentViewController = homeNC; } }
其实绑定侧开菜单的内容页面很简单的,只许设置contentViewController属性就行了。
但是为了增强界面效果,应该给内容页面的导航条增加“用于拉开左侧菜单”的滑动手势。
其次别忘了给revealController属性赋值。现在是UINavigationController,需要进行遍历为其中的页面赋值。
于是我写了一个SideMenuUtil类,并提供了addNavigationGesture、setRevealControllerProperty这两个类方法——
@implementation SideMenuUtil // 设置revealController属性. + (id)setRevealControllerProperty:(id)obj revealController:(GHRevealViewController*)revealController { id rt = nil; BOOL isOK = NO; do { if (nil==obj) break; // IRevealControllerProperty. if ([obj conformsToProtocol:@protocol(IRevealControllerProperty)]) { ((id<IRevealControllerProperty>)obj).revealController = revealController; isOK |= YES; } // UINavigationController. if ([obj isKindOfClass:UINavigationController.class]) { UINavigationController* nc = obj; isOK |= nil!=[self setRevealControllerProperty:nc.topViewController revealController:revealController]; isOK |= nil!=[self setRevealControllerProperty:nc.visibleViewController revealController:revealController]; for (id p in nc.viewControllers) { isOK |= nil!=[self setRevealControllerProperty:p revealController:revealController]; } } } while (0); if (isOK) rt = revealController; return rt; } // 添加导航手势. + (BOOL)addNavigationGesture:(UINavigationController*)navigationController revealController:(GHRevealViewController*)revealController { BOOL rt = NO; do { if (nil==navigationController) break; if (nil==revealController) break; UIPanGestureRecognizer *panGesture = [[UIPanGestureRecognizer alloc] initWithTarget:revealController action:@selector(dragContentView:)]; panGesture.cancelsTouchesInView = YES; [navigationController.navigationBar addGestureRecognizer:panGesture]; } while (0); return rt; }
似乎左侧菜单的宽度不对。于是处理一下viewWillAppear,再次设置一下尺寸——
- (void)viewWillAppear:(BOOL)animated { // 设置自身窗口尺寸 self.view.frame = CGRectMake(0.0f, 0.0f, kGHRevealSidebarWidth, CGRectGetHeight(self.view.bounds)); }
编译运行。哈哈,现在能正常显示主页,及拉出左侧菜单了。
->
3.5 主页的菜单按钮
刚才漏掉主页页面的菜单按钮的处理了,现在补上。
在Storyboard找到主页页面(HomeViewController),将导航条的左侧按钮的selector事件绑定到sideLeftButton_selector方法。然后打开HomeViewController.m,实现代码——
/// 拉开左侧:点击. - (IBAction)sideLeftButton_selector:(id)sender { [self.revealController toggleSidebar:!self.revealController.sidebarShowing duration:kGHRevealSidebarDefaultAnimationDuration]; }
因为有IRevealControllerProperty协议传递了GHRevealViewController对象,所以这时只需调用toggleSidebar方法用于打开左侧菜单。
编译运行。现在主页的菜单按钮能正常工作了。
3.6 菜单的退出按钮
既然已经登陆进去了,自然还需要提供一个注销按钮用于回到登陆页面。
因为我们是使用presentModalViewController以模态方式显示GHRevealViewController的,于是这时应该使用dismissModalViewControllerAnimated退出模态页面。
在Storyboard找到菜单页面(MenuListViewController),将导航条的右侧按钮的selector事件绑定到cancelButton_selector方法。然后打开MenuListViewController.m,实现代码——
/// 取消按钮:点击. - (IBAction)cancelButton_selector:(id)sender { [revealController dismissModalViewControllerAnimated:YES]; }
3.7 更多页面
上面已经成功的实现侧开菜单了。可是只有一个主页页面,无法体现侧开菜单的优势。该开始考虑增加更多的页面了。
例如我想再增加 消息、设置、帮助、反馈 页面。打开Storyboard,增加相应的打开NavigationController与ViewController或TableViewController。将这些NavigationController分别设为MessageNavigationController、SettingNavigationController、HelpNavigationController、FeedbackNavigationController。
然后参考GHSidebarNav的示例代码构造好菜单页面的界面。因代码较多,这里只摘录关键代码,读者可在文本末尾下载源代码。
菜单页面的viewDidLoad被修改为——
- (void)viewDidLoad { [super viewDidLoad]; // 设置自身窗口尺寸 self.view.frame = CGRectMake(0.0f, 0.0f, kGHRevealSidebarWidth, CGRectGetHeight(self.view.bounds)); self.view.autoresizingMask = UIViewAutoresizingFlexibleHeight; // 绑定主页为内容视图(已废弃,仅用于调试). if (NO) { UINavigationController* homeNC = [self.storyboard instantiateViewControllerWithIdentifier:@"HomeNavigationController"]; NSLog(@"instantiateViewControllerWithIdentifier: %@", homeNC); [SideMenuUtil addNavigationGesture:homeNC revealController:revealController]; //homeNC.revealController = revealController; [SideMenuUtil setRevealControllerProperty:homeNC revealController:revealController]; revealController.contentViewController = homeNC; } // 初始化表格. _headers = @[ [NSNull null], @"", @"", ]; _cellInfos = @[ @[ @{kSidebarCellImageKey: [UIImage imageNamed:@"user.png"], kSidebarCellTextKey: NSLocalizedString(@"Home", @"")}, ], @[ @{kSidebarCellImageKey: [UIImage imageNamed:@"user.png"], kSidebarCellTextKey: NSLocalizedString(@"Messages", @"")}, @{kSidebarCellImageKey: [UIImage imageNamed:@"user.png"], kSidebarCellTextKey: NSLocalizedString(@"Setting", @"")}, @{kSidebarCellImageKey: [UIImage imageNamed:@"user.png"], kSidebarCellTextKey: NSLocalizedString(@"Help", @"")}, @{kSidebarCellImageKey: [UIImage imageNamed:@"user.png"], kSidebarCellTextKey: NSLocalizedString(@"Feedback", @"")}, ], @[ @{kSidebarCellImageKey: [UIImage imageNamed:@"user.png"], kSidebarCellTextKey: NSLocalizedString(@"Logout", @"")}, ], ]; _controllers = @[ @[ [self.storyboard instantiateViewControllerWithIdentifier:@"HomeNavigationController"], ], @[ [self.storyboard instantiateViewControllerWithIdentifier:@"MessageNavigationController"], [self.storyboard instantiateViewControllerWithIdentifier:@"SettingNavigationController"], [self.storyboard instantiateViewControllerWithIdentifier:@"HelpNavigationController"], [self.storyboard instantiateViewControllerWithIdentifier:@"FeedbackNavigationController"], ], @[ @"logout", ], ]; // 添加手势. for (id obj1 in _controllers) { if (nil==obj1) continue; for (id obj2 in (NSArray *)obj1) { if (nil==obj2) continue; [SideMenuUtil setRevealControllerProperty:obj2 revealController:revealController]; if ([obj2 isKindOfClass:UINavigationController.class]) { [SideMenuUtil addNavigationGesture:(UINavigationController*)obj2 revealController:revealController]; } } } // ui. UIColor *bgColor = [UIColor colorWithRed:(50.0f/255.0f) green:(57.0f/255.0f) blue:(74.0f/255.0f) alpha:1.0f]; self.view.backgroundColor = bgColor; self.menuTableView.delegate = self; self.menuTableView.dataSource = self; self.menuTableView.backgroundColor = [UIColor clearColor]; [self selectRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0] animated:NO scrollPosition:UITableViewScrollPositionTop]; }
这里参考了GHSidebarNav的示例代码,用数组来存放表格的配置。其中_controllers数组用于存放各个内容页面的导航控制器。注意其中有一项为字符串“@"logout"”,既利用数组存放命令。
怎么处理单元格点击事件呢——
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { [self onSelectRowAtIndexPath:indexPath hideSidebar:YES]; NSLog(@"didSelectRowAtIndexPath: %@", revealController.contentViewController); }
其中调用了onSelectRowAtIndexPath方法来处理——
// 处理菜单项点击事件. - (BOOL)onSelectRowAtIndexPath:(NSIndexPath *)indexPath hideSidebar:(BOOL)hideSidebar { BOOL rt = NO; do { if (nil==indexPath) break; // 获得当前项目. id controller = _controllers[indexPath.section][indexPath.row]; if (nil!=controller) { // 命令. if ([controller isKindOfClass:NSString.class]) { NSString* cmd = controller; if ([cmd isEqualToString:@"logout"]) { [self cancelButton_selector:nil]; rt = YES; break; } } // 页面跳转. if ([controller isKindOfClass:UIViewController.class]) { rt = YES; revealController.contentViewController = controller; if (hideSidebar) { [revealController toggleSidebar:NO duration:kGHRevealSidebarDefaultAnimationDuration]; } } } } while (0); return rt; }
使用isKindOfClass判断对象类型。如果是NSString,则表示是命令,例如“logout”时调用cancelButton_selector进行注销。如果是UIViewController,便设置contentViewController进行切换。
四、展示
登陆,进入主页——
点击功能1,进入下级页面“主页.1”——
在标题条上从左到右滑动,拉出左侧菜单——
点击左侧菜单中的“消息”,切换到消息页面——
点击左上角按钮,弹出左侧菜单。
点击左侧菜单中的“主页”,会发现现在恢复到下级页面“主页.1”——
参考文献——
GHSidebarNav. https://github.com/gresrun/GHSidebarNav
《UIStoryboard Class Reference》. https://developer.apple.com/library/ios/#documentation/UIKit/Reference/UIStoryboard_Class/Reference/Reference.html
源码下载——
https://files.cnblogs.com/zyl910/StoryboardSideMenu.zip