SDWebImage 4.x 播放GIF图片

今天产品部的突然说 iOS 版 App 播放不了 GIF,一接到这个反馈信息,我一开始是不相信的,因为之前的旧版也是用的 SDWebImage 这个库且未做任何代码适配就能够播放 GIF,所以我内心是极不愿相信这是我的锅。

新版 App 重构后也上线了两个多月了,这期间由于 App 未涉及到 GIF 的播放,所以一直未发现该问题(是我工作的疏忽)。

虽然极不愿相信,但问题已出,不得不进行排查。网上随便一搜就发现事情不对,重构后的项目用的是 4.3.3 版本的 SDWebImage,而旧项目用的还是 3.8.2 版本的;而 SDWebImage 在 4.0 版本开始就已经采用 FLAnimatedImage 来播放 GIF 了。

下面是 SDWebImage 关于 GIF 的说明:

Animated Images (GIF) support

  • Starting with the 4.0 version, we rely on FLAnimatedImage to take care of our animated images.
  • If you use cocoapods, add pod 'SDWebImage/GIF' to your podfile.
  • To use it, simply make sure you use FLAnimatedImageView instead of UIImageView.
  • Note: there is a backwards compatible feature, so if you are still trying to load a GIF into a UIImageView, it will only show the 1st frame as a static image by default. However, you can enable the full GIF support by using the built-in GIF coder. See GIF coder
  • Important: FLAnimatedImage only works on the iOS platform. For macOS, use NSImageView with animates set to YES to show the entire animated images and NO to only show the 1st frame. For all the other platforms (tvOS, watchOS) we will fallback to the backwards compatibility feature described above

虽然采用了 FLAnimatedImage,但是也并没废弃之前的功能不是,SDWebImage 还是内建了 GIF 编码器的,只是默认并未启用而已。

SDWebImage 在 Readme 中已经清楚说明了如何播放 GIF:

  • 使用 FLAnimatedImageView 代替 UIImageView
  • 使用 GIF 编码器,详情参看 文档

方案1的优点是节省性能、速度快,但是麻烦;方案2的优点是只需添加一行代码启用 GIF 编码器即可完成需求,但是缺点就是性能相对来说没那么高效。

我个人采用了方案2,毕竟要将项目中的 UIImageView 替换成 FLAnimatedImageView 的工作量相对来说比较大(虽然实际上可能也就半个小时的功夫,但是相对于方案2来说,还是太费时费力了,况且方案2的性能也不是不能接受,毕竟以前也是用的这套方案不是)。


解决方案:

在 UIImageView 的 Category 文件中添加以下代码:

1
2
3
4
5
6
#import <SDWebImage/SDWebImageCodersManager.h>
#import <SDWebImage/SDWebImageGIFCoder.h>

+ (void)initialize {
[SDWebImageCodersManager.sharedInstance addCoder:SDWebImageGIFCoder.sharedCoder];
}

总结

既然用到了第三方库,有空还是要多了解一下版本更新情况。

微信小程序转发按钮的显示与隐藏

在小程序就要上线之际,突然接到一个需求:只有登录的用户才能转发小程序。

在一接到这个需求的时候,我内心是很拒绝的,一时间也是一脸懵逼态,无从下手。在我痛定思痛之后,发现了一对API wx.showShareMenuwx.hideShareMenu,这对API能够显示/隐藏 当前 页面的转发按钮。

有了思路,也有了API,那我只需要在 onShow() 方法中判断一下当前用户是否登录即可,登录就调用 wx.showShareMenu 显示转发按钮,未登录就调用 wx.hideShareMenu 隐藏转发按钮。

虽然我们的小程序并不是所有页面都可以转发,但是起码也有十几二十个页面,每个页面都来粘贴一下这代码,别说会被人鄙视了,就连自己看了都恶心啊,这不是作为一个合格的程序员的修养。

我们可以通过拦截系统的 onShow 方法统一处理,这里就要借助前面的一篇文章:微信小程序扩展 Page 对象

下面话不多说,直接上代码,如果看不懂的话,建议看下前面的文章。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function initPageExtension(Page) {
return (function (page) {

const { onShow, onShareAppMessage } = page;

page.onShow = function (options) {
if (typeof onShow === 'function') {
onShow.call(this, options);
}
if (Reflect.has(this, 'onShareAppMessage')) {
// TODO: 判断登录状态
if ('如果已登录') {
wx.showShareMenu();
} else {
wx.hideShareMenu();
}
}
}

if (Reflect.has(page, 'onShareAppMessage')) {
page.onShareAppMessage = function (options) {
let result = onShareAppMessage.call(this, options);
// 可以修改分享的标题或者给分享的path附加额外参数
return result;
}
}

return Page(page);
});
}

上面的代码还有一点点小瑕疵,就是当前页面不能在登录状态发生改变时及时作出反应;当然也不是不能解决,可以参考这篇文章 为微信小程序增加通知机制 监听登录状态的变化情况。

注:由于 wx.showShareMenu 和 wx.hideShareMenu 控制的是当前页面的转发按钮,假设当前页面栈中有 A、B、C 三个页面,不管你在 A 或 B 中调用这对 API,控制的都是 C 页面的转发按钮,因此需要在每个页面的 onShow 方法中进行判断处理。

为微信小程序增加通知机制

当有多个页面需要监听某个状态(如:登录状态),当状态发生改变时,需要及时通知到每个监听者(页面),这就是通知机制。

源码

新建一个 NotificationCenter.js 文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
/**
* @variable 记录所有注册的通知
*
* key为通知的名称,value为监听该通知的对象数组
*/
let _observerTables = {};

const NC_NAME_KEY = 'NC_NAME_KEY';
const NC_OBSERVER_KEY = 'NC_OBSERVER_KEY';
const NS_CALLBACK_KEY = 'NS_CALLBACK_KEY';

/**
* @class 通知中心管理类
*
* 添加/移除是成对出现的,需要自行移除观察者对象
* 否则将会导致观察者对象不被释放,造成内存泄漏
*
* 如果页面只需要在当前展示时进行监听,则在`onShow()`中添加,在`onHide()`中移除
* 如果页面需要在整个生命周期内进行监听,则在`onLoad()`中添加,在`onUnload()`中移除
*/
class NotificationCenter {

/**
* @method 添加一个观察者对象
*
* @param observer {Object} 观察者(Page对象)
* @param name {String} 通知的名称
* @param callback {String/Function} 回调(接收一个Object类型的参数,如果为字符串则确保observer对象实现了该方法)
*/
static addObserver(observer, name, callback) {
if (typeof observer !== 'object' || observer === null) {
throw new Error(`${observer} is not a Page object.`);
}
if (typeof name !== 'string' || !name.length) {
throw new Error(`${name} is not a string or is empty.`);
}
if (typeof callback === 'string') {
if (typeof Reflect.get(observer, callback) !== 'function') {
throw new Error(`${observer} not implemented ${callback} method.`);
}
} else if (typeof callback !== 'function') {
throw new Error(`${callback} is not a function.`);
}

let observers = Reflect.get(_observerTables, name) || [];
observers.push({
NC_NAME_KEY: name,
NC_OBSERVER_KEY: observer,
NS_CALLBACK_KEY: callback
});
Reflect.set(_observerTables, name, observers);
}

/**
* @method 移除观察者对象
*
* @param observer {Object} 观察者(Page对象)
* @param name {String} 通知的名称, 如果未指定则会移除`observer`注册的所有通知
*/
static removeObserver(observer, name = null) {

const removeObserverBlock = (o, k) => {
let obs = Reflect.get(_observerTables, k).filter(element => {
return (Reflect.get(element, NC_OBSERVER_KEY) != o);
});
if (obs.length) {
Reflect.set(_observerTables, k, obs);
} else {
Reflect.deleteProperty(_observerTables, k);
}
};

if (typeof name === 'string' && name.length) {
// 移除指定通知名称的观察者
if (!Reflect.has(_observerTables, name)) {
return;
}
removeObserverBlock(observer, name);
} else {
// 未指定通知名称, 移除 observer 监听的所有通知事件
for (let key in _observerTables) {
removeObserverBlock(observer, key);
}
}
}

/**
* @method 发送一个通知
*
* @param name {String} 通知的名称
* @param userInfo {Object} 需要传递给观察者的数据,默认为`null`
*/
static postNotificationName(name, userInfo = null) {
if (typeof name !== 'string' || !name.length) {
throw new Error('`name` is illegal parameter.');
}
if (!Reflect.has(_observerTables, name)) {
return;
}

const notification = { name: name, userInfo: userInfo };
const obs = Reflect.get(_observerTables, name);
obs.forEach((o) => {
let observer = Reflect.get(o, NC_OBSERVER_KEY);
let callback = Reflect.get(o, NS_CALLBACK_KEY);
let func = (typeof callback === 'function') ? callback : Reflect.get(observer, callback);
Reflect.apply(func, observer, [notification]);
});
}

}

module.exports = NotificationCenter;

使用示例

  • 页面A监听某个通知

    1、导入文件

    1
    const NotificationCenter = require('NotificationCenter.js');

    2、当页面加载完毕时注册通知(onLoad方法中调用)

    • page 对象必须实现 notificationCallback 方法

      1
      NotificationCenter.addObserver(this, 'NOTIFICATION_NAME', 'notificationCallback');
    • 匿名函数方式回调

      1
      2
      3
      NotificationCenter.addObserver(this, 'NOTIFICATION_NAME', (res) => {
      console.log(res);
      });

      两种注册方式,选择你喜欢的一种即可。

      3、当页面卸载时移除通知(onUnload方法中调用)

      1
      NotificationCenter.removeObserver(this, 'NOTIFICATION_NAME');
  • 页面B中发出通知

    4、发出通知

    1
    NotificationCenter.postNotificationName('NOTIFICATION_NAME', {'userInfo' : null});

注:

1、2和3是成对出现的,添加了注册就要记得移除

2、可以根据实际需求在 onLoad/onUnload、onShow/onHide 中进行添加/移除操作

微信小程序扩展 Page 对象

对于 iOS 开发者来说,如果需要对一个类进行扩展,第一反应就是 runtime + category,有了这组合,我们简直可以为所欲为,为类添加属性、方法、重写某个方法都不在话下。

在小程序中是通过 Page() 函数来注册页面的,而 page 对象就是我们所熟知的控制器。如果我们重写了系统的 Page() 函数,并修改所传递进来的 page 对象,那我们不就是达到了扩展的目的了吗。

源码

1、新建一个 js 文件,文件名随意(假设叫做 page_extension.js),将下面的代码粘贴到该文件中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
* @method 扩展 Page 对象
*
* @param Page {Object} 系统原始的Page对象
* @return {function} 返回新的Page构造器
*/
function initPageExtension(Page) {

// 返回一个新的 Page 构造器
return (function (page) {

// 获取需要hook的方法, 保存方法的原始实现
const { onLoad, onUnload, onShow } = page;

/**
* @method 重写 onLoad 方法
*/
page.onLoad = function (options) {
// 可以在此直接加载模块, this.moduleA = xxxx;
if (typeof onLoad === 'function') {
onLoad.call(this, options);
}
}

/// 可以为 page 扩展任意方法, 注意别和系统的方法重名即可
page.testFunction = function() {
console.log('test function.');
}

// 调用原来的构造方法
return Page(page);
});
}

// 扩展 Page 对象
const originalPage = Page;
Page = initPageExtension(originalPage);

2、在 app.js 中引入该文件即可。

1
require('./utils/page_extension.js');

总结

通过重写 Page() 方法,拿到每个页面的 page 实例对象,我们就可以根据需求做各类扩展了:

  • 重写 Page 系统方法,做特定的逻辑处理
  • 为 Page 统一增加某个功能函数
  • 统一导入某个模块,避免每个js文件都导入一遍,过于繁琐

更多用途你可以自由发挥哈,我只能帮你到这了 :)

UITabBar 点击刷新功能

我们都知道 UITabBar 只能添加 UITabBarItem,而 UITabBarItem 是继承自 NSObject 的,但是我们可以发现 UITabBarItem 内部有一个 _view 属性与私有类 UITabBarButton 关联着,而这个 UITabBarButton 正是我们在 UITabBar 上看到的一个个按钮对象。

经过调试,发现 UITabBarButton 的 UIControlEventTouchUpInside 事件与 UITabBar 的 _buttonUp: 方法关联着,所以我们只需要通过 runtime hook 该方法即可拦截到 UITabBarButton 的点击事件。

这里提醒一下,虽然 UITabBarButton 继承自 UIControl,但是对于是否选中的标记,UITabBarButton 并没有采用 UIControl 的 selected 属性,而是在内部用了一个私有变量 _selected 来标记。

附上 Demo

特性

  • 不依赖 UITabBarControllerDelegate,通过对 UITabBarItem 进行扩展来实现
  • UITabBar 支持刷新动画 ,默认关闭动画
  • UITabBar 支持自定义刷新动画,需遵守XPTabBarRefreshViewAnimating协议,默认动画是UIActivityIndicatorView
  • 支持 iPhone/iPad,适配横竖屏

效果演示

gif

代码使用演示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 根据项目实际场景获取UITabBarItem
UITabBarItem *tabBarItem = self.tabBarItem; //self.navigationController.tabBarItem;
// 启用刷新动画
tabBarItem.enabledRefreshAnimation = YES;
// 自定义动画
tabBarItem.refreshView = [[CustomRefreshView alloc] init];
// 监听刷新回调
[tabBarItem setRefreshBlock:^(UITabBar *tabBar, UITabBarItem *tabBarItem) {
// 发送网络请求刷新数据
NSLog(@"Refresh");
// 当网络请求完毕时记得调用 `stopRefresh` 方法
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[tabBarItem stopRefresh];
});
}];

关于 UITabBarItem 的获取说明

  • 结构1
1
2
3
4
5
6
7
UITabBarController

├── UIViewController

├── UIViewController

└── UIViewController
  • 结构2
1
2
3
4
5
6
7
8
9
UINavigationController

└── UITabBarController

├── UIViewController

├── UIViewController

└── UIViewController

对于上图的结构1、2,直接使用 UIViewController.tabBarItem 即可。

  • 结构3
    1
    2
    3
    4
    5
    6
    7
    8
    9
    UITabBarController

    └── UINavigationController

    ├── UIViewController

    ├── UIViewController

    └── UIViewController

对于上图的结构3,使用 UIViewController.navigationController.tabBarItem 即可。

源码

  • .h 文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#import <UIKit/UIKit.h>

@protocol XPTabBarRefreshViewAnimating;
typedef void(^XPTabBarRefreshBlock)(UITabBar *tabBar, UITabBarItem *tabBarItem);


NS_CLASS_AVAILABLE_IOS(8_0) @interface UITabBarItem (XPTabBarRefresh)

/// 刷新回调
@property (nonatomic, copy) XPTabBarRefreshBlock refreshBlock;
/// 刷新动画视图, 默认`UIActivityIndicatorView`
@property (nonatomic, strong) UIView<XPTabBarRefreshViewAnimating> *refreshView;
/// 是否启用刷新动画, 默认`NO`
@property (nonatomic, assign, getter=isEnabledRefreshAnimation) BOOL enabledRefreshAnimation;

/// 停止刷新(请在`refreshBlock`回调中调用该方法)
- (void)stopRefresh;

@end


NS_CLASS_AVAILABLE_IOS(8_0) @interface UITabBar (XPTabBarRefresh)

@end


@protocol XPTabBarRefreshViewAnimating <NSObject>

/// 开始刷新动画
- (void)startRefreshAnimating;
/// 结束刷新动画
- (void)stopRefreshAnimating;

@end
  • .m 文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
#import "UITabBar+XPTabBarRefresh.h"
#import <objc/runtime.h>

@interface UITabBarItem (XPTabBarRefreshPrivate)

/// 是否正在刷新
@property (nonatomic, assign, getter=isRefreshing) BOOL refreshing;

/// 开始刷新
- (void)startRefresh;

@end

@implementation UITabBarItem (XPTabBarRefreshPrivate)

- (void)startRefresh {
self.refreshing = YES;
if (!self.isEnabledRefreshAnimation) {
return;
}
UIView *containerView = (UIView *)[self valueForKey:@"_view"];
if (containerView == nil || ![containerView isKindOfClass:UIView.class]) {
return;
}
for (UIView *subview in containerView.subviews) {
subview.hidden = YES;
}
UIView<XPTabBarRefreshViewAnimating> *animatingView = self.refreshView;
[animatingView setUserInteractionEnabled:NO];
[animatingView setTranslatesAutoresizingMaskIntoConstraints:NO];
[containerView addSubview:animatingView];
NSDictionary *views = @{@"view" : animatingView};
[NSLayoutConstraint activateConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|[view]|" options:0 metrics:nil views:views]];
[NSLayoutConstraint activateConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"V:|[view]|" options:0 metrics:nil views:views]];
[animatingView startRefreshAnimating];
}

- (void)stopRefresh {
self.refreshing = NO;
if (!self.isEnabledRefreshAnimation) {
return;
}
UIView<XPTabBarRefreshViewAnimating> *animatingView = self.refreshView;
for (UIView *subview in animatingView.superview.subviews) {
subview.hidden = NO;
}
[animatingView stopRefreshAnimating];
[animatingView removeFromSuperview];
}

- (void)setRefreshBlock:(XPTabBarRefreshBlock)refreshBlock {
objc_setAssociatedObject(self, @selector(refreshBlock), refreshBlock, OBJC_ASSOCIATION_COPY);
}

- (XPTabBarRefreshBlock)refreshBlock {
return objc_getAssociatedObject(self, _cmd);
}

- (void)setRefreshView:(UIView<XPTabBarRefreshViewAnimating> *)refreshView {
objc_setAssociatedObject(self, @selector(refreshView), refreshView, OBJC_ASSOCIATION_RETAIN);
}

- (UIView<XPTabBarRefreshViewAnimating> *)refreshView {
UIView<XPTabBarRefreshViewAnimating> *view = objc_getAssociatedObject(self, _cmd);
if (nil == view) {
UIActivityIndicatorView *indicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleGray];
indicator.hidesWhenStopped = YES;
view = (UIView<XPTabBarRefreshViewAnimating> *)indicator;
[self setRefreshView:view];
}
return view;
}

- (void)setEnabledRefreshAnimation:(BOOL)enabledRefreshAnimation {
objc_setAssociatedObject(self, @selector(isEnabledRefreshAnimation), @(enabledRefreshAnimation), OBJC_ASSOCIATION_RETAIN);
}

- (BOOL)isEnabledRefreshAnimation {
return [objc_getAssociatedObject(self, _cmd) boolValue];
}

- (void)setRefreshing:(BOOL)refreshing {
objc_setAssociatedObject(self, @selector(isRefreshing), @(refreshing), OBJC_ASSOCIATION_RETAIN);
}

- (BOOL)isRefreshing {
return [objc_getAssociatedObject(self, _cmd) boolValue];
}

@end


#pragma mark -

@implementation UITabBar (XPTabBarRefresh)

+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
SEL originalSelector = NSSelectorFromString(@"_buttonUp:");
SEL swizllingSelector = @selector(xp_buttonUp:);
Method originalMethod = class_getInstanceMethod(self, originalSelector);
Method swizlledMethod = class_getInstanceMethod(self, swizllingSelector);
BOOL flag = class_addMethod(self, originalSelector, method_getImplementation(swizlledMethod), method_getTypeEncoding(swizlledMethod));
if (flag) {
class_replaceMethod(self, swizllingSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod));
} else {
method_exchangeImplementations(originalMethod, swizlledMethod);
}
});
}

- (void)xp_buttonUp:(UIControl *)sender {
BOOL selected = [[sender valueForKey:@"_selected"] boolValue];
if (selected) {
UITabBarItem *item = self.selectedItem;
XPTabBarRefreshBlock refreshBlock = [item refreshBlock];
if (refreshBlock && !item.isRefreshing) {
[item startRefresh];
refreshBlock(self, item);
return;
}
}
[self xp_buttonUp:sender];
}

@end


#pragma mark -

@interface UIActivityIndicatorView (XPTabBarRefresh)<XPTabBarRefreshViewAnimating>

@end

@implementation UIActivityIndicatorView (XPTabBarRefresh)

- (void)startRefreshAnimating {
[self startAnimating];
}

- (void)stopRefreshAnimating {
[self stopAnimating];
}

@end

最后提一下,除了 runtime 之外,也可以通过 UITabBarControllerDelegate 代理实现,可以从 - (BOOL)tabBarController:(UITabBarController *)tabBarController shouldSelectViewController:(UIViewController *)viewController- (void)tabBarController:(UITabBarController *)tabBarController didSelectViewController:(UIViewController *)viewController 这两个方法下手。

网上随便一搜都是基于 - (void)tabBarController:(UITabBarController *)tabBarController didSelectViewController:(UIViewController *)viewController 方法的实现,这里就不再累赘了。

修复 iOS12.1 UITabBar 布局错乱的bug

此 Bug 是在 iOS 12.1 Beta2 版本中被引入的,没想到在 iOS 12.1 正式版中并未修复

Bug触发条件

  • 使用 UITabBarController + UINavigationController 组合
  • UITabBar带半透明效果,isTranslucent 属性为 YES
  • UIViewController的 hidesBottomBarWhenPushed 属性为 YES
  • 通过导航栏返回上一页时(导航栏返回按钮 or 屏幕左侧的滑动返回手势)

Bug演示

gif

解决方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@interface XPTabBarButton : UIView

@end

@implementation XPTabBarButton

+ (void)load {
if (@available(iOS 12.1, *)) {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Class originalClass = NSClassFromString(@"UITabBarButton");
SEL originalSelector = @selector(setFrame:);
SEL swizzledSelector = @selector(xp_setFrame:);

Method originalMethod = class_getInstanceMethod(originalClass, originalSelector);
Method swizzledMethod = class_getInstanceMethod(self, swizzledSelector);
class_replaceMethod(originalClass,
swizzledSelector,
method_getImplementation(originalMethod),
method_getTypeEncoding(originalMethod));
class_replaceMethod(originalClass,
originalSelector,
method_getImplementation(swizzledMethod),
method_getTypeEncoding(swizzledMethod));
});
}
}

- (void)xp_setFrame:(CGRect)frame {
if (!CGRectIsEmpty(self.frame)) {
// for iPhone 8/8Plus
if (CGRectIsEmpty(frame)) {
return;
}
// for iPhone XS/XS Max/XR
frame.size.height = MAX(frame.size.height, 48.0);
}
[self xp_setFrame:frame];
}

@end

不知为何,在非刘海屏机型上,frame 的 size 为 {0, 0},但是在刘海屏上却不是这个值,而是高度为 33.0 的尺寸(也不确定这个值是否固定为33.0)。

使用

直接将代码拷贝到项目即可,无需进行任何方法调用。

iOS禁用第三方输入法

当我们需要用户输入数字时,我们会将键盘类型设置为 UIKeyboardTypeNumberPadUIKeyboardTypePhonePadUIKeyboardTypeDecimalPad 等数字类型的键盘。但是如果用户使用了第三方输入法(比如搜狗),那就无法达到强制输入数字的目的了,因为搜狗输入法在这些 UIKeyboardType 枚举类型中,除了常规的 0~9 数字键外,还额外带了其他符号键,用户可以输入一些特殊字符,此时如果想要达到效果,就只能通过重写遵守 UITextFieldDelegate 并实现 - (BOOL)textField:(UITextField *)textField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString *)string 方法,在这里通过正则判断用户输入的是否为数字。

如果不想这么麻烦,那就干脆直接禁用第三方输入法吧 :)

1
2
3
4
5
6
- (BOOL)application:(UIApplication *)application shouldAllowExtensionPointIdentifier:(UIApplicationExtensionPointIdentifier)extensionPointIdentifier {
if ([extensionPointIdentifier isEqualToString:UIApplicationKeyboardExtensionPointIdentifier]) {
return NO;
}
return YES;
}

PS:如果 secureTextEntry 属性为 YES,那么将会强制使用系统键盘,不管你有没有禁用第三方输入法,苹果这是为了安全考虑。

iOS 刘海屏适配

今年苹果发布了三款 iPhone,分别为 iPhone XS、iPhone XS Max 以及 iPhone XR,尽管大家都在吐槽刘海屏,但是苹果今年已经是 iPhone 系列标配刘海屏了。

虽然苹果为我们带来了更大屏幕尺寸的 iPhone XS Max 和 iPhone XR,尽管一个是6.5英寸一个是6.1英寸,并且 iPhone XS Max 的分辨率更是达到了 1242px × 2688px,但是对于我们开发者来说,这两者的物理尺寸都一个样,都是 414×896pt,而 6/7/8 Plus 系列的宽度也是 414pt,所以你可以把他们看作是 plus 系列的加长版。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
/// 是否为异形屏(刘海屏)
BOOL isShapedScreen(void)
{
if (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPhone) {
if (@available(iOS 11.0, *)) {
static BOOL result = NO;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
CGFloat width = MIN(UIScreen.mainScreen.bounds.size.width, UIScreen.mainScreen.bounds.size.height);
CGFloat height = MAX(UIScreen.mainScreen.bounds.size.width, UIScreen.mainScreen.bounds.size.height);
if (@available(iOS 12.0, *)) {
if (width == 414.0 && height == 896.0) {
result = YES; // iPhone XS Max / iPhone XR
}
}
if (width == 375.0 && height == 812.0) {
result = YES; // iPhone X / iPhone XS
}
});
return result;
}
}
return NO;
}

/// 是否为iPhone X / iPhone XS
BOOL iPhoneX(void)
{
if (@available(iOS 11.0, *)) {
static BOOL result = NO;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
if (isShapedScreen()) {
CGFloat height = MAX(UIScreen.mainScreen.bounds.size.width, UIScreen.mainScreen.bounds.size.height);
if (height == 812.0) {
result = YES;
}
}
});
return result;
}
return NO;
}

/// 是否为iPhone XS Max
BOOL iPhoneXSMax(void)
{
if (@available(iOS 12.0, *)) {
static BOOL result = NO;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
if (isShapedScreen()) {
CGFloat height = MAX(UIScreen.mainScreen.bounds.size.width, UIScreen.mainScreen.bounds.size.height);
if (height == 896.0 && UIScreen.mainScreen.scale == 3.0) {
result = YES;
}
}
});
return result;
}
return NO;
}

/// 是否为iPhone XR
BOOL iPhoneXR(void)
{
if (@available(iOS 12.0, *)) {
static BOOL result = NO;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
if (isShapedScreen()) {
CGFloat height = MAX(UIScreen.mainScreen.bounds.size.width, UIScreen.mainScreen.bounds.size.height);
if (height == 896.0 && UIScreen.mainScreen.scale == 2.0) {
result = YES;
}
}
});
return result;
}
return NO;
}

附上 Swift 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/// 是否为异形屏(刘海屏)
let isShapedScreen : Bool = {
if UI_USER_INTERFACE_IDIOM() == .phone {
if #available(iOS 11.0, *) {
let width = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height)
let height = max(UIScreen.main.bounds.width, UIScreen.main.bounds.height)
if #available(iOS 12.0, *) {
if width == 414.0 && height == 896.0 {
return true // iPhone XS Max / iPhone XR
}
}
if (width == 375.0 && height == 812.0) {
return true // iPhone X / iPhone XS
}
}
}
return false
}()

/// 是否为iPhone X / iPhone XS
let iPhoneX : Bool = {
if #available(iOS 11.0, *) {
if isShapedScreen {
let height = max(UIScreen.main.bounds.width, UIScreen.main.bounds.height)
return (height == 812.0)
}
}
return false
}()

/// 是否为iPhone XS Max
let iPhoneXSMax : Bool = {
if #available(iOS 12.0, *) {
if isShapedScreen {
let height = max(UIScreen.main.bounds.width, UIScreen.main.bounds.height)
let scale = UIScreen.main.scale
if height == 896.0 && scale == 3.0 {
return true
}
}
}
return false
}()

/// 是否为iPhone XR
let iPhoneXR : Bool = {
if #available(iOS 12.0, *) {
if isShapedScreen {
let height = max(UIScreen.main.bounds.width, UIScreen.main.bounds.height)
let scale = UIScreen.main.scale
if height == 896.0 && scale == 2.0 {
return true
}
}
}
return false
}()

设备 分辨率 图片资源
12.9” iPad Pro 2048px × 2732px @2x
10.5” iPad Pro 1668px × 2224px @2x
9.7” iPad 1536px × 2048px @2x
7.9” iPad mini 4 1536px × 2048px @2x
iPhone XS Max 1242px × 2688px @3x
iPhone XS 1125px × 2436px @3x
iPhone XR 828px × 1792px @2x
iPhone X 1125px × 2436px @3x
iPhone 8 Plus 1242px × 2208px @3x
iPhone 8 750px × 1334px @2x
iPhone 7 Plus 1242px × 2208px @3x
iPhone 7 750px × 1334px @2x
iPhone 6s Plus 1242px × 2208px @3x
iPhone 6s 750px × 1334px @2x
iPhone SE 640px × 1136px @2x

参考:

UIButton 被禁用时显示一个菊花提示用户正在加载中

当点击按钮后,可能会执行一个比较耗时的操作,但是又不方便在整个页面进行提示时,此时如果我们仅仅只是把按钮禁用了以防用户重复点击,但是用户并不知道操作是否还在继续,用户体验不够友好;但是如果我们在按钮被禁用时,自动显示一个转圈圈的小菊花进行提示,那用户体验就会好很多,用户一看就知道当前操作正在进行中。

演示

附上我项目中的一个使用场景:重新定位的按钮,点击后重新定位用户的位置,但是不影响页面的其他操作。

gif

要求

  • iOS 9.0+ (使用了 NSLayoutAnchor, 如需要兼容低版本 iOS, 请自行改用 NSLayoutConstraint)

源码

新建一个 UIButton 的分类

  • .h 头文件
1
2
/// 当按钮被禁用时, 是否显示正在加载的状态提示, 默认`NO`
@property (nonatomic, assign, getter=isShowLoadingWhenDisabled) IBInspectable BOOL showLoadingWhenDisabled;
  • .m 文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65

+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
SEL originalSelector = @selector(setEnabled:);
SEL swizllingSelector = @selector(xp_setEnabled:);
Method originalMethod = class_getInstanceMethod(self, originalSelector);
Method swizllingMethod = class_getInstanceMethod(self, swizllingSelector);
BOOL flag = class_addMethod(self, originalSelector, method_getImplementation(swizllingMethod), method_getTypeEncoding(swizllingMethod));
if (flag) {
class_replaceMethod(self, swizllingSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod));
} else {
method_exchangeImplementations(originalMethod, swizllingMethod);
}
});
}

static char const XPButtonLoadingIndicatorKey = '\0';

/// 重写 `setEnabled:` 方法, 当按钮被禁用时显示一个菊花提示
- (void)xp_setEnabled:(BOOL)enabled {
if (self.isShowLoadingWhenDisabled) {
if (enabled) {
UIActivityIndicatorView *indicatorView = objc_getAssociatedObject(self, &XPButtonLoadingIndicatorKey);
[indicatorView stopAnimating];
} else {
// 显示菊花
UIActivityIndicatorView *indicatorView = objc_getAssociatedObject(self, &XPButtonLoadingIndicatorKey);
if (nil == indicatorView) {
indicatorView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleGray];
[indicatorView setTranslatesAutoresizingMaskIntoConstraints:NO];
[indicatorView setHidesWhenStopped:YES];
[self addSubview:indicatorView];
[indicatorView.centerXAnchor constraintEqualToAnchor:self.centerXAnchor].active = YES;
[indicatorView.centerYAnchor constraintEqualToAnchor:self.centerYAnchor].active = YES;
objc_setAssociatedObject(self, &XPButtonLoadingIndicatorKey, indicatorView, OBJC_ASSOCIATION_RETAIN_NONATOMIC);

// 清除禁用状态下的图片/文字(设置`hidden`属性无效)
CGRect rect = (CGRect){CGPointZero, CGSizeMake(1.0, 1.0)};
UIGraphicsBeginImageContext(rect.size);
CGContextRef context = UIGraphicsGetCurrentContext();
CGContextSetFillColorWithColor(context, UIColor.clearColor.CGColor);
CGContextFillRect(context, rect);
UIImage *clearImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
[self setImage:clearImage forState:UIControlStateDisabled];
[self setBackgroundImage:clearImage forState:UIControlStateDisabled];
[self setTitle:NSString.new forState:UIControlStateDisabled];
}
[indicatorView startAnimating];
}
}
[self xp_setEnabled:enabled];
}

- (void)setShowLoadingWhenDisabled:(BOOL)showLoadingWhenDisabled {
objc_setAssociatedObject(self, @selector(isShowLoadingWhenDisabled), @(showLoadingWhenDisabled), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
if (showLoadingWhenDisabled && self.state == UIControlStateDisabled) {
[self setEnabled:self.isEnabled];
}
}

- (BOOL)isShowLoadingWhenDisabled {
return [objc_getAssociatedObject(self, _cmd) boolValue];
}

用法

1、 设置按钮的 showLoadingWhenDisabled 属性为 YES

2、 设置按钮的启用/禁用 setEnabled:

注意

  • 不建议将按钮默认为禁用状态

解决 UIButton 快速点击后重复提交数据

有时候我们快速点击按钮多次,会触发多次点击事件,最终导致数据提交多次,这是很没必要的。

说明

  • 默认开启防重复点击功能,默认间隔时间 0.5

如果你的按钮有特殊的需求,可以通过将 enabledRejectRepeatTap 设置为 NO 来禁用该功能。

代码

  • UIButton+RejectRepeatTapGesture.h 头文件代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#import <UIKit/UIKit.h>

/// 解决按钮快速点击重复提交数据的问题
@interface UIButton (RejectRepeatTapGesture)

/// 重复点击的时间间隔, 默认`0.5s`
@property (nonatomic, assign) IBInspectable double repeatTimeInterval;
/// 是否启用防重复功能, 默认`YES`
@property (nonatomic, assign, getter=isEnabledRejectRepeatTap) IBInspectable BOOL enabledRejectRepeatTap;

@end


/// 回调, 按钮的`target`对象遵守该协议即可接收回调事件
@protocol UIButtonRejectRepeatTapGestureCallback <NSObject>

/**
当重复点击时的回调方法

@param button 被点击的按钮
@param timeInterval 两次点击之间的时间间隔
*/
- (void)rejectRepeatTapGestureFromButton:(UIButton *)button timeInterval:(NSTimeInterval)timeInterval;

@end
  • UIButton+RejectRepeatTapGesture.m 代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
#import "UIButton+RejectRepeatTapGesture.h"
#import <objc/message.h>

static char const kRepeatTapRecordsKey = '\0';

@implementation UIButton (RejectRepeatTapGesture)

#pragma mark - Lifecycle

+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
SEL originalSelector = @selector(sendAction:to:forEvent:);
SEL swizllingSelector = @selector(rrtg_sendAction:to:forEvent:);
Method originalMethod = class_getInstanceMethod(self, originalSelector);
Method swizllingMethod = class_getInstanceMethod(self, swizllingSelector);
BOOL flag = class_addMethod(self, originalSelector, method_getImplementation(swizllingMethod), method_getTypeEncoding(swizllingMethod));
if (flag) {
class_replaceMethod(self, swizllingSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod));
} else {
method_exchangeImplementations(originalMethod, swizllingMethod);
}
});
}

/**
重写`sendAction:to:forEvent:`系统方法

根据`target-action`来确定唯一的key,在规定时间内避免多次触发`[target action]`
通过该机制能够有效避免类似 UIImagePickerController 的拍照按钮以及 UITableView 的左滑删除按钮点击无效的问题
*/
- (void)rrtg_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event {
if (event.type == UIEventTypeTouches && self.isEnabledRejectRepeatTap) {
NSString * const key = [NSString stringWithFormat:@"%p%p", action, target];
NSMutableDictionary *dictionary = objc_getAssociatedObject(self, &kRepeatTapRecordsKey);
if (dictionary == nil) {
dictionary = [NSMutableDictionary dictionary];
objc_setAssociatedObject(self, &kRepeatTapRecordsKey, dictionary, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
NSTimeInterval lasttimeTapTime = [[dictionary objectForKey:key] doubleValue];
NSTimeInterval currentTime = [[NSDate date] timeIntervalSince1970];
if (lasttimeTapTime > 0.0) {
NSTimeInterval interval = currentTime - lasttimeTapTime;
if (interval <= self.repeatTimeInterval) {
if ([target conformsToProtocol:@protocol(UIButtonRejectRepeatTapGestureCallback)]) {
id<UIButtonRejectRepeatTapGestureCallback> delegate = (id<UIButtonRejectRepeatTapGestureCallback>)target;
[delegate rejectRepeatTapGestureFromButton:self timeInterval:interval];
}
return;
}
}
[dictionary setObject:@(currentTime) forKey:key];
}
[self rrtg_sendAction:action to:target forEvent:event];
}

#pragma mark - setter & getter

- (void)setRepeatTimeInterval:(NSTimeInterval)repeatTimeInterval {
objc_setAssociatedObject(self,
@selector(repeatTimeInterval),
@(repeatTimeInterval),
OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (NSTimeInterval)repeatTimeInterval {
NSTimeInterval timeInterval = [objc_getAssociatedObject(self, _cmd) doubleValue];
if (timeInterval >= 0.01) {
return timeInterval;
}
return 0.5; // default.
}

- (void)setEnabledRejectRepeatTap:(BOOL)enabledRejectRepeatTap {
objc_setAssociatedObject(self,
@selector(isEnabledRejectRepeatTap),
@(enabledRejectRepeatTap),
OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (BOOL)isEnabledRejectRepeatTap {
id obj = objc_getAssociatedObject(self, _cmd);
if (obj) {
return [obj boolValue];
}
return YES; // default.
}

@end