FEATURED · 精选文章

iOS侧滑菜单从选型到自研:手势冲突与动画实现全解析

发布时间 / 2026/9/7 9:31:59
来源 / 创域科博编辑部
栏目 / 资讯中心
iOS侧滑菜单从选型到自研:手势冲突与动画实现全解析 简介一份iOS侧滑菜单栏的轻量级源码Demo面向正在学习或实现该类交互的移动端开发者。示例采用点击按钮后移动自身视图的方式展示菜单而不是依赖边缘滑动手势清晰演示了侧滑视图创建、按钮触发、动画过渡及状态管理等基础环节适合作为自定义抽屉菜单的入门参考。资源共25个文件压缩包大小36KB以Objective-C的m/h源文件为核心另含有Xcode工程配置如plist、pbxproj、xcscheme及多语言strings文件结构简洁便于直接打开运行。目前已有198人学习下载。借助该工程可快速查看自定义UIView、UIButton事件绑定和UIView动画的搭配用法也能了解Auto Layout在侧滑布局中的处理方式对搭建完整侧滑菜单栏有一定借鉴意义。1. 侧滑菜单的几种主流实现方案与选型思路侧滑菜单栏通常叫 Drawer 或 Sidebar 或侧滑抽屉在 iOS 里是一个很常见但又特别容易踩坑的交互控件。国内主流 App 里像知乎、掘金、闲鱼这类产品都有类似“左侧滑出个人中心/功能导航”的结构而很多刚转 iOS 的同学一上来就找三方库或者直接照着印象里的“抽屉效果”乱加手势最后往往卡在手势冲突、UIWindow 层级混乱、转场动画错乱这些破事儿上。我最早做侧滑菜单是在 2016 年那时候 MMDrawerController 和 RESideMenu 还比较流行后来组件库更新慢了再加上业务定制越来越重我开始在项目里手写这个抽屉容器。写多了以后你会发现这个东西本质上就两件事一个能拖动的主内容视图 一个从左侧滑出的菜单视图。听上去简单但真正把它做成“可复用的、不崩的、手势顺滑的”控件里面还是有一堆门道。先聊方案选型。1.1 系统原生支持与各自的适用场景很多人第一反应是“iOS 不是有现成的抽屉吗”其实 iOS 系统并没有一个专门给 iPhone 用的左滑抽屉控件只有UISplitViewController这类偏向 iPad 的形态它在 iPhone 上默认是折叠效果表现成 push 或者 tab 切换并不是真正意义的“侧滑抽屉”。所以如果你的需求是“主界面右侧内容不动左侧菜单滑出来”大概率都是自定义实现。换个方向说如果你做的是“侧滑返回上一页”那系统自带的UINavigationController的interactivePopGestureRecognizer就够用了。但这件事经常被混为一谈有次和同事排查问题他一直说“系统有侧滑直接用就行”结果他要的效果是抽屉系统手势根本做不了折腾了大半天才把思路理顺。另外UITableView自带的左滑删除、侧滑操作按钮swipeActions也不是抽屉这是“单元格内操作”不需要改变整个屏幕的布局。想要抽屉效果就得把“主内容视图”和“菜单视图”放到同一个容器里并且由手势来控制菜单视图的 frame 或 transform。从实践角度看系统原生能力只适合以下情况用UISplitViewController做 iPad 双栏布局iPhone 上自动折叠成 push用导航控制器自带的边缘返回手势做“侧滑返回”不做菜单UIAlertController弹出底部菜单这不算侧滑。如果你要的是真正的“左侧抽屉 半透明遮罩 主界面右移 弹簧效果”那基本只有两条路找靠谱的三方库或者自己封装一个容器控制器。1.2 为什么大多数团队最终都走了自定义这条路早期我用过几个主流三方库简单列一下特点库名语言维护状态优点缺点MMDrawerControllerObjective-C已停止维护功能完整动画流畅结构偏重手动内存管理时代产物RESideMenuObjective-C已停止维护轻量代码简单缺少自适应适配容易和新系统碰撞SideMenuSwiftSwift维护中上手简单文档清晰定制灵活性偏低自研容器任意—可控性最高可定制阴影、遮罩、手势需要自己处理细节问题后来业务越来越复杂菜单里要嵌UITableView、要加用户头像区域、还要支持右上角角标、夜间模式、动态菜单项三方库要么不好扩展要么跟内部 UI 组件库冲突。再加上现在 Swift SwiftUI 的混合项目越来越多很多老的三方库已经不再更新有的甚至在新版本系统上出现奇怪的动画异常最终我还是决定自研。自研容器最大的好处是“心里有数”。你知道菜单盖到哪个窗口、遮罩 alpha 是多少、状态栏什么时候变化、点击菜单里的 cell 怎么自动收起。这些细节在三方库里往往是黑盒出了问题不好查。2. 核心细节解析手势驱动与动画原理很多新手做侧滑菜单翻车通常不是布局写不出来而是不明白“手势驱动动画”的底层逻辑。这里我把几个关键概念拆开讲。2.1 抽屉结构的视图层级该怎么搭一个标准的侧滑菜单视图层级至少要有三层容器视图Container View整个抽屉的根视图负责把手势加在合适的位置主内容视图Content View展示正常业务页面一般是导航控制器或 TabBar 控制器菜单视图Menu View从左侧滑出的那个面板宽度一般是 280pt 或屏幕宽度的 0.75 倍。菜单视图在默认状态下应该放在左侧屏幕外比如frame.origin.x -menuWidth。主内容视图在初始状态下frame就是全屏。当手势向右拖动时主内容视图和菜单视图一起往右移动视觉上就像把主界面“推开”露出下面的菜单。这里有个很容易搞错的点菜单视图是放在主内容视图上面还是放在下面从视觉遮挡关系来看菜单是从左侧“滑出来”通常会盖在主界面上方但从渲染层级看如果把菜单视图放在主内容视图上面菜单出现时它会直接覆盖在主界面之上主内容视图往右移的时候菜单也跟着往右移这样其实是“两张卡片一起平移”的效果。真正想要“推开”的感觉应该是主内容视图往右移动而菜单视图保持在左边固定不动直到主内容视图让出空间。所以推荐的层级是这样// 容器控制器的 viewDidLoad let contentVC MainViewController() let menuVC MenuViewController() addChild(contentVC) view.addSubview(contentVC.view) contentVC.view.frame view.bounds addChild(menuVC) view.insertSubview(menuVC.view, at: 0) // 菜单放在主内容下面 menuVC.view.frame CGRect(x: -menuWidth, y: 0, width: menuWidth, height: view.bounds.height)主内容视图默认盖在菜单视图上面因此屏幕上看不到菜单。向右拖动主内容视图时菜单视图其实是“露出来”的这个视觉模型比“菜单在上方滑过”要自然得多也更接近 Material Design 的抽屉效果。稍微补充一点国内 App 常见的“主界面整体右移并露出左侧菜单”这种效果本质上跟上面这个模型一致。如果你在某些 App 里看到菜单还带有一点缩放或者主界面带缩放那就是在 transform 里加了CGAffineTransformScale的额外处理。后面会细说参数怎么调。2.2 手势驱动的关键参数百分比转场与速度iOS 里的手势驱动动画核心是UIPanGestureRecognizer的translation(in:)方法。这个方法返回手指从手势开始到当前这一点的位移变化量。拿到位移之后我们需要把它换算成“当前位置应该偏移多少距离”。比如菜单宽度是 280手指向右拖了 140那么进度就是 0.5。这时候主视图的 transform 应该是CGAffineTransform(translationX: 140, y: 0)。当手指继续拖满 280主视图刚好偏移一个屏幕宽度菜单完整露出。代码层面大致是这样objc private func handlePanGesture(_ recognizer: UIPanGestureRecognizer) { let translationX recognizer.translation(in: view).x let progress max(0.0, min(1.0, translationX / menuWidth)) switch recognizer.state { case .changed: contentView.transform CGAffineTransform(translationX: progress * menuWidth, y: 0) overlayView.alpha progress * 0.5 menuView.transform CGAffineTransform(translationX: progress * menuWidth * 0.3, y: 0) // 可选的减速效果 case .ended, .cancelled: let velocityX recognizer.velocity(in: view).x let shouldOpen progress 0.5 || velocityX 800 if shouldOpen { openMenu() } else { closeMenu() } default: break } }这里有几个可以调整的细节菜单视图可以加一个轻微的translation让它看起来有“错层”感但位移系数不要超过 0.5否则会显得菜单“飘”出来遮罩层的 alpha 建议从 0 到 0.4~0.6太深会压暗主界面太浅又起不到聚焦效果结束手势时不能只判断progress 0.5还要考虑速度。如果用户快速右滑但只拖了 0.3应该让菜单展开这种交互更符合直觉。动画部分iOS 里做弹性效果可以用UIView.animate(withDuration:delay:usingSpringWithDamping:initialSpringVelocity:options:)。我常用的参数是duration 0.35damping 0.8springVelocity 0.5出来的效果不会太弹又有一点灵动感。从性能角度来说transform比frame更高效因为transform走的是 GPU 合成层不触发layoutSubviews。如果你在拖动过程中直接改frame主视图内部如果有复杂的约束可能会导致每一帧都重新布局动画就会掉帧。这点务必要注意。3. 实操过程手写一个可复用的侧滑抽屉接下来是整个项目的重头戏手写一个可复用的抽屉容器。这里我以 Swift 为例用UIViewController容器的方式实现方便将已有页面直接塞进去。3.1 容器控制器的基类设计我先定义一个DrawerContainerViewController它是整个抽屉的宿主。对外暴露两个方法openMenu()和closeMenu()同时允许外界配置菜单宽度、是否开启遮罩点击关闭、是否支持边缘滑动手势等。class DrawerContainerViewController: UIViewController { enum DrawerState { case open case closed } private(set) var state: DrawerState .closed var menuWidth: CGFloat 280 private var contentVC: UIViewController! private var menuVC: UIViewController! private var overlayView UIView() private var panGesture: UIPanGestureRecognizer! init(contentVC: UIViewController, menuVC: UIViewController) { self.contentVC contentVC self.menuVC menuVC super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError(init(coder:) has not been implemented) } override func viewDidLoad() { super.viewDidLoad() setupChildren() setupGesture() } private func setupChildren() { addChild(contentVC) view.addSubview(contentVC.view) contentVC.view.frame view.bounds contentVC.didMove(toParent: self) addChild(menuVC) view.insertSubview(menuVC.view, at: 0) menuVC.view.frame CGRect(x: -menuWidth, y: 0, width: menuWidth, height: view.bounds.height) menuVC.didMove(toParent: self) overlayView.backgroundColor .black overlayView.alpha 0 overlayView.frame view.bounds view.insertSubview(overlayView, aboveSubview: contentVC.view) let tapGesture UITapGestureRecognizer(target: self, action: #selector(handleTapOverlay)) overlayView.addGestureRecognizer(tapGesture) } private func setupGesture() { panGesture UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) view.addGestureRecognizer(panGesture) } // MARK: - Actions func openMenu() { state .open UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.85, initialSpringVelocity: 0.5, options: .curveEaseInOut) { self.contentVC.view.transform CGAffineTransform(translationX: self.menuWidth, y: 0) self.overlayView.alpha 0.4 self.menuVC.view.transform CGAffineTransform(translationX: self.menuWidth * 0.2, y: 0) } } func closeMenu() { state .closed UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 1.0, initialSpringVelocity: 0.5, options: .curveEaseInOut) { self.contentVC.view.transform .identity self.overlayView.alpha 0 self.menuVC.view.transform .identity } } objc private func handleTapOverlay() { closeMenu() } objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { // 手势处理逻辑见下方 } }这样设计的好处在于外层业务拿到这个容器后只需要把自己的首页和菜单页传入即可不需要关心内部怎么控制动画。后续如果想增加“夜间模式/无遮罩/右侧菜单”之类的变体也只需要在这个类里扩展。3.2 手势处理从手动拖拽到自动回弹现在补全handlePan的具体实现。这里有一个细节如果抽屉已经在打开状态用户想往左滑关闭此时translationX会出现负数。所以在计算 progress 时需要根据当前状态做区分。objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { let translationX recognizer.translation(in: view).x let velocityX recognizer.velocity(in: view).x // 根据当前状态决定可以使用的手势位移范围 let currentOffset: CGFloat if state .closed { currentOffset min(max(translationX, 0), menuWidth) } else { currentOffset menuWidth min(max(translationX, -menuWidth), 0) } let progress currentOffset / menuWidth switch recognizer.state { case .changed: contentVC.view.transform CGAffineTransform(translationX: currentOffset, y: 0) overlayView.alpha progress * 0.4 menuVC.view.transform CGAffineTransform(translationX: progress * menuWidth * 0.2, y: 0) case .ended, .cancelled, .failed: let shouldOpen: Bool if state .closed { shouldOpen progress 0.5 || velocityX 800 } else { shouldOpen !(progress 0.5 || velocityX -800) } shouldOpen ? openMenu() : closeMenu() default: break } }这段代码的核心思路是任何时候都基于currentOffset计算当前进度而不是依赖上一次的translation。这样可以避免状态切换时视觉跳变。如果你把translation当作“从视图初始位置算起”的位移就很容易出现在打开状态下又拖动时手指移了 10pt 但主视图跳回原位的 bug。关于边缘手势如果你希望只在屏幕左边缘大约 20~30pt 区域内触发滑出可以在setupGesture里把UIPanGestureRecognizer包装成边缘手势或者直接用UIScreenEdgePanGestureRecognizer。不过用边缘手势有个坑就是它无法在抽屉“打开状态”下反向滑动关闭所以更稳妥的做法是在全屏范围添加 pan 手势但在UIGestureRecognizerDelegate里根据当前手势起点判断是否允许触发。extension DrawerContainerViewController: UIGestureRecognizerDelegate { func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) - Bool { if state .open { return true } // 关闭状态下只允许从左边缘触发 let location gestureRecognizer.location(in: view) return location.x 30 } }3.3 遮罩层与状态栏的处理细节抽屉打开时看到的半透明黑色遮罩层其实是UIView。这个视图默认在内容视图上面、菜单视图下面不对我的层级里它是插在contentVC.view上层所以会盖住主界面但不会盖住菜单视图。点击遮罩层即可关闭菜单这在移动端是标准交互。这里有几个小坑遮罩层不能在菜单打开的初始瞬间就出现否则会有一个从 0 到 0.4 的“闪变”遮罩层上要禁用主界面的交互最简单的方法是overlayView.isUserInteractionEnabled true点击事件触发closeMenu()之后再把交互重新关掉状态栏的样式打开抽屉时通常希望菜单页的风格接管。如果你用的UIViewControllerBasedStatusBarAppearance是YES可以在容器里 overridechildForStatusBarStyle让菜单页控制状态栏样式。override var childForStatusBarStyle: UIViewController? { return state .open ? menuVC : contentVC } override var childForStatusBarHidden: UIViewController? { return state .open ? menuVC : contentVC }这样设置之后抽屉打开时状态栏文字颜色会跟随菜单页关闭后恢复主页面。不会出现“主页面是黑色背景菜单打开后状态栏还是白色文字看不清”的尴尬情况。3.4 与导航栈和 TabBar 的集成实际项目中抽屉很少是单页应用通常抽屉里的某个菜单项点击后需要 push 到二级页面甚至更深。这里就涉及到导航栈的问题。我通常把“内容视图”直接设计成一个UINavigationController然后把整个DrawerContainerViewController作为根控制器。这样菜单项点击后可以拿到contentVC也就是那个 UINavigationController进行pushViewController。注意push 的页面是在主内容区域里的是“完整覆盖整个主界面”的 push还是“在抽屉内容区域里”的 push这是两种完全不同的视觉体验。如果在抽屉打开状态下点击菜单项希望先收起来再 push可以先调用closeMenu()再 push中间加一个completion回调。我提供的closeMenu()可以改成带闭包参数的版本方便业务方在动画结束后继续做操作。func closeMenu(completion: (() - Void)? nil) { state .closed UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 1.0, initialSpringVelocity: 0.5, options: .curveEaseInOut) { // ... 动画内容 } completion: { _ in completion?() } }另外如果需要支持横屏或者 iPad 分屏当前menuWidth是固定值可能不够用建议在viewWillTransition(to:with:)里动态调整override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) let newWidth min(320, size.width * 0.75) menuWidth newWidth if state .open { contentVC.view.transform CGAffineTransform(translationX: newWidth, y: 0) menuVC.view.frame CGRect(x: -newWidth, y: 0, width: newWidth, height: size.height) } }4. 常见问题与排查技巧实录这部分是实际项目中最容易翻车的地方我挑几个高频问题详细聊。4.1 手势冲突与 ScrollView、TableView 边缘返回的博弈侧滑菜单最经典的问题就是和UINavigationController自带的侧滑返回手势冲突。如果你把抽屉手势加在容器视图上而容器里嵌的是一个UIScrollView或UITableView左滑右滑时很容易被系统误判成边缘返回或者列表滚动。解决方案核心是在UIGestureRecognizerDelegate里实现gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:)让抽屉手势和内部滚动手势不要同时生效。我的经验是func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) - Bool { // 如果内部是 UITableView/UIScrollView且用户是在横向滚动这时不应该触发抽屉 if let scrollView otherGestureRecognizer.view as? UIScrollView { // 判断当前是否在左边缘 let location gestureRecognizer.location(in: view) if location.x 30 scrollView.contentOffset.x 0 { return true } return false } return true }需要注意的是这个判断在“抽屉已经打开、用户想关闭”的场景下要放开限制否则用户想往左滑关闭时会被 list 的滚动手势吃掉。另外如果你用的是UIScreenEdgePanGestureRecognizer它本身就和系统返回手势冲突所以通常不建议在UINavigationController容器里同时使用边缘手势除非你能确保该页面没有系统返回手势需求。4.2 动画卡顿与离屏渲染问题做侧滑动画时shadowPath、cornerRadius、maskToBounds都是性能杀手。很多人在主内容视图上加阴影效果一拖动就掉帧。解决方案是给阴影设置固定shadowPath或者用shouldRasterize配合rasterizationScale。contentVC.view.layer.shadowColor UIColor.black.cgColor contentVC.view.layer.shadowOpacity 0.25 contentVC.view.layer.shadowRadius 4 contentVC.view.layer.shadowOffset CGSize(width: 0, height: 0) contentVC.view.layer.shadowPath UIBezierPath(rect: contentVC.view.bounds).cgPath // 拖动结束后别忘了更新 shadowPath override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() contentVC.view.layer.shadowPath UIBezierPath(rect: contentVC.view.bounds).cgPath }另外如果菜单页里又有头像圆角、毛玻璃效果、复杂图层打开瞬间出现卡顿是正常的因为一次性创建太多图层。可以先把菜单页在初始化阶段就加载完成但设为隐藏状态等真正需要时再做动画避免首帧创建开销。实测下来UITabBarController底部 tab 切换时也有类似坑大致思路相通。4.3 内容页点击事件被遮罩挡住之后的状态恢复遮罩层打开后overlayView.alpha 0.4它阻挡了用户对主内容区的一切点击这是预期行为。但有一个小问题如果你在菜单打开时用户点击遮罩关闭遮罩上的alpha变化会有一个渐变过程此时如果用户快速点击两次可能导致openMenu和closeMenu交替调用动画互相打断出现视图卡在中间的状态。建议给openMenu和closeMenu加一个“动画执行中”的布尔标记动画未结束时忽略新的调用private var isAnimating false func openMenu() { guard !isAnimating else { return } isAnimating true // ... UIView.animate(...) { _ in self.isAnimating false } }这个“防抖”处理在真实交互中非常有必要尤其是用户在快速连续滑动或点击时能避免大量状态混乱。4.4 与系统返回手势、全屏手势的配合如果你使用了FDFullscreenPopGesture这类库来实现全屏返回默认它会把UINavigationController的返回手势从边缘扩展成全屏范围。这样一来抽屉手势和返回手势的冲突就更加明显。我的做法是在DrawerContainerViewController内部启用抽屉手势时暂时禁用内容页导航控制器的interactivePopGestureRecognizer等到抽屉关闭后再恢复。func openMenu() { contentNavVC?.interactivePopGestureRecognizer?.isEnabled false // ... } func closeMenu() { contentNavVC?.interactivePopGestureRecognizer?.isEnabled true // ... }这个细节在真机调试时才会暴露模拟器上往往不敏感。如果你在某个页面发现“抽屉打开后返回手势还能触发导致页面跳走”的异常基本就是这里没有处理。5. 优化扩展菜单里的子页面、深色模式与键盘处理侧滑菜单不只是一个简单的“露出面板”它往往还承载了复杂业务。我这里再说几个拓展场景。5.1 菜单里的 UITableView 与自动收起菜单页通常是一个UITableView点击某个 cell 后需要执行“收起菜单 切换主页面”。如果菜单里同时还有UISwitch、UIButton等控件要注意点击这些控件时不要误触发 cell 的didSelectRowAt。常见做法是把 cell 的selectionStyle设为.none再结合hitTest判断点击区域。菜单里如果有选中的高亮状态建议用selectedIndex记录而不是依赖UITableView的selectedRow因为菜单收起再展开时系统有时不会自动恢复选中状态。5.2 深色模式适配抽屉菜单的背景色、文字颜色、分割线颜色、阴影深度在深色模式下的表现完全不同。最省事的方式是使用系统色板和UIColor { (traitCollection) - UIColor in }动态颜色。如果你使用了固定十六进制色值深色模式下会显得特别刺眼。我习惯在菜单页根视图的背景上用 dynamic color阴影在深色模式下调低透明度这样在 iPhone 上切换浅色/深色模式时整体观感不会突兀。5.3 键盘弹出时抽屉的表现如果菜单里刚好有一个搜索框用户点击搜索框弹出键盘时抽屉需要往上顶吗我的经验是尽量不要顶保持抽屉整体不动键盘覆盖在底部即可。在UIKeyboardWillShowNotification回调里你只需要确保抽屉的表格能滚动到搜索框位置而不是整体调整抽屉的 Y 坐标。因为侧滑抽屉是“水平方向”交互垂直方向的键盘调整会打破视觉平衡。6. 踩坑记录与体验优化小结写自研抽屉控件的过程中我最有印象的几个坑值得分享一下。第一千万别在viewDidLoad里就把菜单视图的frame写在view.bounds基础上然后以为它永远不变。如果项目支持横竖屏切换、分屏或者打开菜单时设备发生旋转view.bounds变了但 frame 没更新就会出现菜单视图位置漂移甚至完全找不到的情况。解决办法就是重写viewDidLayoutSubviews在里面根据当前状态强制更新菜单的 frame。第二手势事件的translation是累计值而不是增量值。很多新手在handlePan里用lastTranslation做差值计算反而容易出现跳变。用我前面写的“基于状态 当前位移”的计算方式可以避免绝大多数字段增量累加的问题。第三动画闭包里的强引用循环。如果容器里强引用了contentVC和menuVC动画闭包又一再持有self在页面 pop 或 dismiss 后内存可能无法释放。建议养成[weak self]的习惯。这个在 Xcode 的 Debug Memory Graph 里能看到明显问题线上表现就是内存持续上升。第四真机调试时如果用了旧系统的 iPhonetransform和shadowPath的表现可能跟新系统不一样。iOS 15 之后UIView动画的springDamping参数对新旧系统观感略有差异。如果发现老设备动画“发飘”把damping调到 1.0关闭弹簧感。最后再分享一个小技巧做完抽屉之后可以给容器控制器加一个state变化回调比如onStateChange: ((DrawerState) - Void)这样业务方就能在菜单打开/关闭时做一些埋点统计、状态栏联动、甚至是主界面暂停/恢复的操作。这个设计在多个页面复用时非常省心。本文还有配套的精品资源点击获取
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻