【译】UIBarButtonItem & iOS 11

原文链接:UIBarButtonItem & iOS 11 by Kassem Wridan

发布时间:Oct 21, 2017

概述

iOS 11 和 Xcode 9已经正式发布1个月了,一个有趣的变化是UINavigationBar中的UIBarButtonItem的行为变化。

这里是我迄今为止的发现……

变化

总体来看,有这些要点:

  • 自定义ViewUIBarButtonItem的点击区域变小了
  • 返回按钮的点击区域也变小了
  • 前一个页面标题更新时,当前页面的返回按钮的文本将不再自动更新
  • 自定义View需要对Auto Layout友好
  • 值为负的UIBarButtonSystemItemFixedSpaceUIBarButtonItem已失效,并且现在最小值为8
  • 覆写alignmentRectInsets可能导致自定义View超出点击范围。

下面的部分将深入说明以上各点。

点击区域

下面截图显示iOS 10系统中的截图,包含两个带有自定义View的UIBarButtonItem。使用视图调试器检查视图可以看到自定义View的真实大小。

他们的点击区域实际上会更大一点,接近下图中的红色区域。

http://www.matrixprojects.net/images/content/BP1710001/vd-custom-view-ios-10-tap-area.png

但是在iOS 11中,这个行为发生了改变。点击区域现在和自定义View的大小一致。

http://www.matrixprojects.net/images/content/BP1710001/vd-custom-views-ios11-tap-area.png

这个变化导致可用性变差,因为用户点击按钮变得困难了。要恢复到点击区域到更大的范围,自定义View需要在设置大小的时候加入额外间距。一个解决方法就是创建一个有最小尺寸的wrapper视图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class WrapperView: UIView {
let minimumSize: CGSize = CGSize(width: 44.0, height: 44.0)
let underlyingView: UIView
init(underlyingView: UIView) {
self.underlyingView = underlyingView
super.init(frame: underlyingView.bounds)

underlyingView.translatesAutoresizingMaskIntoConstraints = false
addSubview(underlyingView)

NSLayoutConstraint.activate([
underlyingView.leadingAnchor.constraint(equalTo: leadingAnchor),
underlyingView.trailingAnchor.constraint(equalTo: trailingAnchor),
underlyingView.topAnchor.constraint(equalTo: topAnchor),
underlyingView.bottomAnchor.constraint(equalTo: bottomAnchor),
heightAnchor.constraint(greaterThanOrEqualToConstant: minimumSize.height),
widthAnchor.constraint(greaterThanOrEqualToConstant: minimumSize.width)
])
}
// ...
}

http://www.matrixprojects.net/images/content/BP1710001/vd-wrapper-view-ios11-expanded.png

虽然和iOS 10上的方式不同,但也已经扩大了点击区域。

返回按钮

点击区域

如果标题很长,返回按钮上面的点击区域也会变得很小。在iOS 10上,返回按钮有比较大的最小尺寸和点击区域。

http://www.matrixprojects.net/images/content/BP1710001/back-button-ios10-tap-area.png

但是在iOS 11上,返回按钮的尺寸和点击区域变得更小。更糟糕的是当前页面的标题看起来像按钮的文本。

http://www.matrixprojects.net/images/content/BP1710001/back-button-ios11-tap-area.png

一个方式出现这个情况的做法是在上个页面手动设置一个backBarButtonItem

1
navigationItem.backBarButtonItem = UIBarButtonItem(title: "First Screen", style: .plain, target: nil, action: nil)

http://www.matrixprojects.net/images/content/BP1710001/vd-ios11-backbarbuttonitem.png

这个解决方法并不理想,因为:

  • 如果我们能提前知道所有页面的标题,我们要在页面展示之前设置返回按钮。
  • 如果我们不能提前知道所有页面的标题,我们要在任何地方设置返回按钮。
  • 我们将丢失返回按钮的默认渲染行为,将不能根据可用空间自动适应尺寸。(苹果文档
    • < First Screen
    • < Back
    • <

文本动态更新

iOS 11中的另一个变化是返回按钮的文本。例如,如果第一个页面的标题包含动态更新的数字。

http://www.matrixprojects.net/images/content/BP1710001/ios10-first-screen.png

在iOS 10上,如果标题更新了,下一个页面的返回按钮的文本也会更新。

http://www.matrixprojects.net/images/content/BP1710001/ios10-second-screen.png

http://www.matrixprojects.net/images/content/BP1710001/ios10-second-screen-updated.png

但是在iOS 11上,返回按钮变成了静态的,不会动态更新。

http://www.matrixprojects.net/images/content/BP1710001/ios11-second-screen.png

解决这个问题的一个简单做法就是在第一个页面手动管理backBarButtonItem

1
navigationItem.backBarButtonItem = UIBarButtonItem(title: "First (\(counter))", style: .plain, target: nil, action: nil)

注意:每次都需要一个新的返回按钮实例,更新现有的实例并没有效果。

Auto Layout

UINavigationBar现在在内部使用Auto Layout布局它的子View,同时也包含自定义View。今年早些时候,WWDC的Updating Your Apps for iOS 11演讲有提到这点。

在iOS11上检视导航栏显示按钮现在在内部通过栈视图(译者注:stack view)管理。

http://www.matrixprojects.net/images/content/BP1710001/vd-ios11-stack-view.png

如果提供的自定义View正确实现了sizeThatFitsintrinsicContentSize方法,将不会有影响。

提醒一下,如果设置自定义View的translatesAutoresizingMaskIntoConstraintsfalse,将导致在iOS 10(如果你还支持这个版本)上出现约束冲突。这将导致自定义View被错误的放置到左上角。一个简单的解决方法是加上iOS 11的运行时可用性判断。

1
2
3
4
5
let customView = createCustomView()
if #available(iOS 11, *) {
customView.translatesAutoresizingMaskIntoConstraints = false
}
navigationItem.rightBarButtonItem = UIBarButtonIte(customView: customView)

自定义对齐

对于那些想精确控制自定义View对齐方式,特别是距离屏幕右边的间距,的人来说,在iOS 11以前,经常使用下面两个“trick”:

  • 第一个是使用一个带有固定负值的空白项:UIBarButtonItem(barButtonSystemItem: .fixedSpace …)
  • 另一个是在自定义View上覆写alignmentRectInsets方法。

遗憾的是这两个行为在iOS 11上都变了。

固定空白项

使用自定义View的时候,默认间距是16pt

http://www.matrixprojects.net/images/content/BP1710001/vd-no-spacer-ios10.png

假如我们想减少到8pt,在iOS 10上,使用一个固定宽度为-8UIBarButtonItem可以实现。

1
2
3
4
5
6
7
8
let spacer = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil)
spacer.width = -8
let barButtonsItems = [
spacer,
UIBarButtonItem(customView: createCustomButton()),
UIBarButtonItem(customView: createCustomButton())
]
navigationItem.rightBarButtonItems = barButtonItems

http://www.matrixprojects.net/images/content/BP1710001/vd-negative-spacer-ios10.png

不幸的是,在iOS 11上这个方法失效了。

http://www.matrixprojects.net/images/content/BP1710001/vd-negative-spacer-ios11.png

并且,从现象上来看任何小于8pt的值都会被忽略。这实际上是和在按钮项之间使用的固定宽度项的行为统一了。

http://www.matrixprojects.net/images/content/BP1710001/vd-negative-spacer-ios11-expanded.png

对齐缩进

控制按钮对齐的第二个trick是覆写alignmentRectInsets方法。

1
2
3
4
5
6
7
class CustomView: UIView {
var alignmentRectInsetsOverride: UIEdgeInsets?
override var alignmentRectInsets: UIEdgeInsets {
return alignmentRectInsetsOverride ?? super.alignmentRectInsets
}
// ...
}

设置UIEdgeInsets(top: 0, left: -8, bottom: 0, right: 8)这个值可以让上一个例子得到理想的对齐效果。

http://www.matrixprojects.net/images/content/BP1710001/vd-alginment-insets-ios11.png

这个方法在iOS 11上依旧有效,但是有个小缺陷。仔细看一下,会发现最后一项的一小部分超出了栈视图的范围,那部分将收不到触摸事件,会导致那一项的点击区域变小!

http://www.matrixprojects.net/images/content/BP1710001/vd-alginment-insets-ios11-expanded.png

变通方案

重新看一下上面的在iOS 11上的固定空白项的例子,会发现一个有意思的副作用,边距比通常值变小了。

http://www.matrixprojects.net/images/content/BP1710001/vd-spacer-ios11-expanded-margin.png

是这样,这个现象在最后一个UIBarButtonItem是非自定义View的时候会发生。

来看下这个例子,非自定义View的UIBarButtonItem,到屏幕边缘的距离是8pt。

http://www.matrixprojects.net/images/content/BP1710001/native-items-ios11.png

反之,当使用自定义View的时候,边距是16pt。

http://www.matrixprojects.net/images/content/BP1710001/custom-views-ios11.png

结合这些观察结果,我们在iOS 11上同样可以为自定义View实现8pt的边距。在末尾添加一个固定空白项可以使内部栈视图的边距减少,然后使用alignmentRectInsets使自定义View偏移和空白项宽度相等的距离,这样可以使自定义视图和内部栈视图的末尾对齐。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func alignedBarButtonItems() -> [UIBarButtonItem] {
let spacer = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil)
spacer.width = 8
let barButtonsItems = [
spacer,
UIBarButtonItem(customView: createCustomButton(offset: spacer.width)),
UIBarButtonItem(customView: createCustomButton(offset: spacer.width)),
]
return barButtonsItems
}

func createCustomButton(offset: CGFloat = 0) -> UIButton {
let button = CustomButton(frame: CGRect(x:0, y: 0, width: 24, height: 24))
button.alignmentRectInsetsOverride = UIEdgeInsets(top: 0, left: -offset, bottom: 0, right: offset)
button.translatesAutoresizingMaskIntoConstraints = false
return button
}

http://www.matrixprojects.net/images/content/BP1710001/ios11-space-with-alginment-expanded.png

尽管这个变通方案有趣可行,但是它最多移动到8pt,移动更多会超出内部栈视图的边界。还有,这个方案依赖文档中没有提及的UIKit的行为,这个行为很可能在未来会改变(就像定宽负值空白项一样)。

开发者论坛中还有一些其他建议方案,可以让内部栈视图的边距消失——尽管他们需要自定义UINavigationBar子类并修改内部行为(medeling with it’s internals),也不是很理想。

总结

谁能想到关于UIBarButtonItem竟可以写这么多内容,我们就写了。如果发现问题或者其他优雅的解决方法,欢迎留言。