用户以多种方式操纵他们的iOS设备,例如触摸屏幕或摇动设备。
iOS会解释用户何时以及如何操作硬件并将此信息传递到您的应用程序。
您的应用程序以自然和直观的方式响应操作的次数越多,对用户而言越有吸引力的体验。

相关资源

GitHub粒子发射和复制图层示例GitHub粘性控件示例GitHub弹性动画CALayer分析CAEmitter分析

你真的了解UIResponder吗?,UIResponder

1:首先查看一下关于UIResponder的定义

NS_CLASS_AVAILABLE_IOS(2_0) @interface UIResponder : NSObject

//响应链中负责传递事件的方法
- (nullable UIResponder*)nextResponder;

//一个响应对象成为第一响应者的一个前提是它可以成为第一响应者,可以用这个进行判断,默认值为NO
- (BOOL)canBecomeFirstResponder;   
//如果我们希望将一个响应对象作为第一响应者,则可以使用以下方法,如果对象成为第一响应者,则返回YES;否则返回NO
- (BOOL)becomeFirstResponder;

//是否可以辞去第一响应者,默认值为YES
- (BOOL)canResignFirstResponder;  
//辞去第一响应者
- (BOOL)resignFirstResponder;

//判定一个响应对象是否是第一响应者
- (BOOL)isFirstResponder;

//响应触摸事件
//手指按下的时候调用
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
//手指移动的时候调用
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
//手指抬起的时候调用
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
//取消(非正常离开屏幕,意外中断)
- (void)touchesCancelled:(nullable NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;

//响应移动事件
//移动事件开始
- (void)motionBegan:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
//移动事件结束
- (void)motionEnded:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
//移动事件取消
- (void)motionCancelled:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);

//响应远程控制事件
- (void)remoteControlReceivedWithEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(4_0);

//通过这个方法告诉UIMenuController它内部应该显示什么内容,”复制”、”粘贴”等
- (BOOL)canPerformAction:(SEL)action withSender:(nullable id)sender NS_AVAILABLE_IOS(3_0);
//默认的实现是调用canPerformAction:withSender:方法来确定对象是否可以调用action操作。如果我们想要重写目标的选择方式,则应该重写这个方法。
- (nullable id)targetForAction:(SEL)action withSender:(nullable id)sender NS_AVAILABLE_IOS(7_0);

//UIResponder提供了一个只读方法来获取响应链中共享的undo管理器,公共的事件撤销管理者
@property(nullable, nonatomic,readonly) NSUndoManager *undoManager NS_AVAILABLE_IOS(3_0);

@end

UIResponder提供了几个方法来管理响应链,包括让响应对象成为第一响应者、放弃第一响应者、检测是否是第一响应者以及传递事件到下一响应者的方法;在UIKit中,UIApplication、UIView、UIViewController这几个类都是直接继承自UIResponder类。另外SpriteKit中的SKNode也是继承自UIResponder类。因此UIKit中的视图、控件、视图控制器,以及我们自定义的视图及视图控制器都有响应事件的能力。

 

知识点1:事件分发制机hit-Testing;每当我们点击了一下iOS设备的屏幕,UIKit就会生成一个事件对象UIEvent,然后会把这个Event分发给当前active的app;

告知当前活动的app有事件之后,UIApplication
单例就会从事件队列中去取最新的事件,然后分发给能够处理该事件的对象。这些事件按照先进先出的顺序来处理。当处理事件时,程序的UIApplication对象会从队列头部取出一个事件对象,将其分发出去。UIApplication 获取到Event之后,Application就纠结于到底要把这个事件传递给谁,这时候就要依靠HitTest来决定了。

iOS中,hit-Testing的作用就是找出这个触摸点下面的View是什么,HitTest会检测这个点击的点是不是发生在这个View上,如果是的话,就会去遍历这个View的subviews,直到找到最小的能够处理事件的view,如果整了一圈没找到能够处理的view,则返回自身。

yzc579亚洲城官网 1

假设我们现在点击到了图中的E,hit-testing将进行如下步骤的检测(不包含重写hit-test并且返回非默认View的情况)

1、触摸点在ViewA内,所以检查ViewA的Subview B、C

2、触摸点不在ViewB内,触摸点在ViewC内部,所以检查ViewC的Subview D、E

3、触摸点不在ViewD内,触摸点发生在ViewE内部,并且ViewE没有subview,所以ViewE属于ViewA中包含这个点的最小单位,所以ViewE变成了该次触摸事件的hit-TestView

PS.

1、默认的hit-testing顺序是按照UIView中Subviews的逆顺序

2、如果View的同级别Subview中有重叠的部分,则优先检查顶部的Subview,如果顶部的Subview返回nil, 再检查底部的Subview

3、Hit-Test也是比较聪明的,检测过程中有这么一点,就是说如果点击没有发生在某View中,那么该事件就不可能发生在View的Subview中,所以检测过程中发现该事件不在ViewB内,也直接就不会检测在不在ViewF内。也就是说,如果你的Subview设置了clipsToBounds=NO,实际显示区域可能超出了superView的frame,你点击超出的部分,是不会处理你的事件的,就是这么任性!

Hit-Test的检查机制如上所示,当确定了Hit-TestView时,如果当前的application没有忽略触摸事件
(UIApplication:isIgnoringInteractionEvents),则application就会去分发事件(sendEvent:->keywindow:sendEvent:)

UIView中提供两个方法用来确定hit-testing View,如下所示

 - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event; // recursively calls -pointInside:withEvent:. point is in the receiver's coordinate system
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event; // default returns YES if point is in bounds

当一个View收到hitTest消息时,会调用自己的pointInside:withEvent:方法,如果pointInside返回YES,则表明触摸事件发生在我自己内部,则会遍历自己的所有Subview去寻找最小单位(没有任何子view)的UIView,如果当前View.userInteractionEnabled =
NO,enabled=NO(UIControl),或者alpha<=0.01,
hidden等情况的时候,hitTest就不会调用自己的pointInside了,直接返回nil,然后系统就回去遍历兄弟节点;

知识点2:事件传递;从上面我们可以知道关于事件的分发制机,并确定好处理事件的hit_text,接着就是处理事件的顺序,正好跟事件分发制机相反,最有机会处理事件的对象是hit-test视图或第一响应者。如果这两者都不能处理事件,UIKit就会将事件传递到响应链中的下一个响应者。每一个响应者确定其是否要处理事件或者是通过nextResponder方法将其传递给下一个响应者。这一过程一直持续到找到能处理事件的响应者对象或者最终没有找到响应者。

yzc579亚洲城官网 2

 

当系统检测到一个事件时,将其传递给初始对象,这个对象通常是一个视图。然后,会按以下路径来处理事件(我们以上图为例):

1.初始视图(initial
view)尝试处理事件。如果它不能处理事件,则将事件传递给其父视图。
2.初始视图的父视图(superview)尝试处理事件。如果这个父视图还不能处理事件,则继续将视图传递给上层视图。
3.上层视图(topmost
view)会尝试处理事件。如果这个上层视图还是不能处理事件,则将事件传递给视图所在的视图控制器。
4.视图控制器会尝试处理事件。如果这个视图控制器不能处理事件,则将事件传递给窗口(window)对象。
5.窗口(window)对象尝试处理事件。如果不能处理,则将事件传递给单例app对象。
6.如果app对象不能处理事件,则丢弃这个事件。
从上面可以看到,视图、视图控制器、窗口对象和app对象都能处理事件。另外需要注意的是,手势也会影响到事件的传递。

知识点3:响应链中负责传递事件的方法nextResponder;UIResponder类并不自动保存或设置下一个响应者,该方法的默认实现是返回nil。子类的实现必须重写这个方法来设置下一响应者(如果要实现一个继承于UIResponder,就要重写给它设置一个下一个响应者是谁)。UIView的实现是返回管理它的UIViewController对象(如果它有)或者其父视图。而UIViewController的实现是返回它的视图的父视图;UIWindow的实现是返回app对象;而UIApplication的实现是返回nil。所以,响应链是在构建视图层次结构时生成的。

知识点4:辞去第一响应者resignFirstResponder,resignFirstResponder默认也是返回YES。需要注意的是,如果子类要重写这个方法,则在我们的代码中必须调用super的实现。 

知识点5:默认情况下,多点触摸是被禁用的。为了接受多点触摸事件,我们需要设置响应视图的multipleTouchEnabled属性为YES。

知识点6:默认情况下,程序的每一个window都有一个undo管理器,它是一个用于管理undo和redo操作的共享对象。然而,响应链上的任何对象的类都可以有自定义undo管理器。例如,UITextField的实例的自定义管理器在文件输入框放弃第一响应者状态时会被清理掉。当需要一个undo管理器时,请求会沿着响应链传递,然后UIWindow对象会返回一个可用的实例。

知识点7:canPerformAction结合UIMenuController的运用

UIMenuController是UIKit里面的控件,UIMenuController的作用在开发中弹出的菜单栏,包含那些复制、粘贴等,也可以自定义要弹出的选择及操作;UITextField、UIWebView、UITextView自带有这种UIMenuController效果;

实例1:在Label中的运用,并且修改的UIMenuController选择内容

#import "JHLabel.h"

@implementation JHLabel

/** 不管控件是通过xib stroyboard 还是纯代码  提供两种初始化的操作都调用同一个方法 */
- (instancetype)initWithFrame:(CGRect)frame
{
    if (self = [super initWithFrame:frame]) {
        [self setupTap];
    }
    return self;
}
/** 不管控件是通过xib stroyboard 还是纯代码  提供两种初始化的操作都调用同一个方法 */
- (void)awakeFromNib
{
    [self setupTap];
}
/** 设置敲击手势 */
- (void)setupTap
{

    self.text = @"author:会跳舞的狮子";
    //已经在stroyboard设置了与用户交互,也可以用纯代码设置
//    self.userInteractionEnabled = YES;

    //当前控件是label 所以是给label添加敲击手势
   [self addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(labelClick)]];

}
/** 点击label触发的方法 */
- (void)labelClick
{
    // 让label成为第一响应者 \
    一定要写这句话  因为这句话才是主动让label成为第一响应者
    [self becomeFirstResponder];

    // 获得菜单
    UIMenuController *menu = [UIMenuController sharedMenuController];

    // 设置菜单内容 \
    因为menuItems是数组 官方没有给出需要传入什么对象,但是以经验可以判断出需要传入的是UIMenuItem对象 \
    而且显示是按顺序的
    menu.menuItems = @[
                       [[UIMenuItem alloc] initWithTitle:@"顶" action:@selector(ding:)],
                       [[UIMenuItem alloc] initWithTitle:@"回复" action:@selector(reply:)],
                       [[UIMenuItem alloc] initWithTitle:@"举报" action:@selector(warn:)]
                       ];

    // 菜单最终显示的位置 \
    有两种方式: 一种是以自身的bounds  还有一种是以父控件的frame 
    [menu setTargetRect:self.bounds inView:self];
//    [menu setTargetRect:self.frame inView:self.superview];

    // 显示菜单
    [menu setMenuVisible:YES animated:YES];
}

#pragma mark - UIMenuController相关
/**
 * 让Label具备成为第一响应者的资格
 */
- (BOOL)canBecomeFirstResponder
{
    return YES;
}

/**
 * 通过第一响应者的这个方法告诉UIMenuController可以显示什么内容
 */
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender
{
    if ( (action == @selector(copy:) && self.text) // 需要有文字才能支持复制
        || (action == @selector(cut:) && self.text) // 需要有文字才能支持剪切
        || action == @selector(paste:)
        || action == @selector(ding:)
        || action == @selector(reply:)
        || action == @selector(warn:)) return YES;

    return NO;
}

#pragma mark - 监听MenuItem的点击事件
/** 剪切 */
- (void)cut:(UIMenuController *)menu
{
    //UIPasteboard 是可以在应用程序与应用程序之间共享的 \
    (应用程序:你的app就是一个应用程序 比如你的QQ消息可以剪切到百度查找一样)
    // 将label的文字存储到粘贴板
    [UIPasteboard generalPasteboard].string = self.text;
    // 清空文字
    self.text = nil;
}
/** 赋值 */
- (void)copy:(UIMenuController *)menu
{
    // 将label的文字存储到粘贴板
    [UIPasteboard generalPasteboard].string = self.text;
}
/** 粘贴 */
- (void)paste:(UIMenuController *)menu
{
    // 将粘贴板的文字赋值给label
    self.text = [UIPasteboard generalPasteboard].string;
}

//如果方法不实现,是不会显示出来的
- (void)ding:(UIMenuController *)menu
{
    NSLog(@"%s %@", __func__, menu);
}

- (void)reply:(UIMenuController *)menu
{
    NSLog(@"%s %@", __func__, menu);
}

- (void)warn:(UIMenuController *)menu
{
    NSLog(@"%s %@", __func__, menu);
}
@end

知识点8:禁用一些长按的操作设置

代码1:禁用所有长按文本框操作

#pragma mark - 禁用所有长按文本框操作
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender {
    if ([UIMenuController sharedMenuController]) {
        [UIMenuController sharedMenuController].menuVisible = NO;
    }
    return NO;
}

代码2:禁用部分长按文本框操作

#pragma mark - 禁用部分长按文本框操作
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender
{
    //禁用选择、全选、粘贴功能
    //...
    if (action == @selector(paste:))
        return NO;
    if (action == @selector(select:))
        return NO;
    if (action == @selector(selectAll:))
        return NO;
    //...
    return [super canPerformAction:action withSender:sender];
}

实例:

#import <UIKit/UIKit.h>

@interface DDTextField : UITextField

@end


#import "DDTextField.h"

@implementation DDTextField

#pragma mark -
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender {
    if (action == @selector(paste:))
        return YES;
    if (action == @selector(cut:))
        return YES;
    if (action == @selector(copy:))
        return YES;
    if (action == @selector(select:))
        return YES;
    if (action == @selector(selectAll:))
        return YES;
    return NO;
}

@end

2:访问快捷键命令

我们的应用可以支持外部设备,包括外部键盘。在使用外部键盘时,使用快捷键可以大大提高我们的输入效率。因此从iOS7后,UIResponder类新增了一个只读属性keyCommands,来定义一个响应者支持的快捷键;

typedef NS_OPTIONS(NSInteger, UIKeyModifierFlags) {
    UIKeyModifierAlphaShift     = 1 << 16,  // Alppha+Shift键
    UIKeyModifierShift          = 1 << 17, //Shift键
    UIKeyModifierControl        = 1 << 18,  //Control键
    UIKeyModifierAlternate      = 1 << 19,  //Alt键
    UIKeyModifierCommand        = 1 << 20,  //Command键
    UIKeyModifierNumericPad     = 1 << 21,   //Num键
} NS_ENUM_AVAILABLE_IOS(7_0);

//按键命令类:
NS_CLASS_AVAILABLE_IOS(7_0) @interface UIKeyCommand : NSObject <NSCopying, NSSecureCoding>

- (instancetype)init NS_DESIGNATED_INITIALIZER;
- (nullable instancetype)initWithCoder:(NSCoder *)aDecoder NS_DESIGNATED_INITIALIZER;

//输入字符串
@property (nonatomic,readonly) NSString *input;
//按键调节器
@property (nonatomic,readonly) UIKeyModifierFlags modifierFlags;
//按指定调节器键输入字符串并设置事件
@property (nullable,nonatomic,copy) NSString *discoverabilityTitle NS_AVAILABLE_IOS(9_0);

+ (UIKeyCommand *)keyCommandWithInput:(NSString *)input modifierFlags:(UIKeyModifierFlags)modifierFlags action:(SEL)action;

// Key Commands with a discoverabilityTitle _will_ be discoverable in the UI.
+ (UIKeyCommand *)keyCommandWithInput:(NSString *)input modifierFlags:(UIKeyModifierFlags)modifierFlags action:(SEL)action discoverabilityTitle:(NSString *)discoverabilityTitle NS_AVAILABLE_IOS(9_0);

@end

知识点1:一个支持硬件键盘命令的响应者对象可以重新定义这个方法并使用它来返回一个其所支持快捷键对象(UIKeyCommand)的数组(在UIResponder
(UIResponderKeyCommands)分类中)。每一个快捷键命令表示识别的键盘序列及响应者的操作方法。我们用这个方法返回的快捷键命令数组被用于整个响应链。当与快捷键命令对象匹配的快捷键被按下时,UIKit会沿着响应链查找实现了响应行为方法的对象。它调用找到的第一个对象的方法并停止事件的处理。

3:UIResponder (UIResponderKeyCommands)分类

//响应者类的按键命令类类目
@interface UIResponder (UIResponderKeyCommands)

//组合快捷键命令(装有多个按键的数组)
@property (nullable,nonatomic,readonly) NSArray<UIKeyCommand *> *keyCommands NS_AVAILABLE_IOS(7_0); // returns an array of UIKeyCommand objects<
@end

@interface NSObject(UIResponderStandardEditActions)   // these methods are not implemented in NSObject

//剪贴
- (void)cut:(nullable id)sender NS_AVAILABLE_IOS(3_0);
//复制
- (void)copy:(nullable id)sender NS_AVAILABLE_IOS(3_0);
//粘贴
- (void)paste:(nullable id)sender NS_AVAILABLE_IOS(3_0);
//选择
- (void)select:(nullable id)sender NS_AVAILABLE_IOS(3_0);
//选择全部
- (void)selectAll:(nullable id)sender NS_AVAILABLE_IOS(3_0);
//删除
- (void)delete:(nullable id)sender NS_AVAILABLE_IOS(3_2);
//从左到右写入字符串(居左)
- (void)makeTextWritingDirectionLeftToRight:(nullable id)sender NS_AVAILABLE_IOS(5_0);
//从右到左写入字符串(居右)
- (void)makeTextWritingDirectionRightToLeft:(nullable id)sender NS_AVAILABLE_IOS(5_0);
//切换字体为黑体(粗体)
- (void)toggleBoldface:(nullable id)sender NS_AVAILABLE_IOS(6_0);
//切换字体为斜体
- (void)toggleItalics:(nullable id)sender NS_AVAILABLE_IOS(6_0);
//给文字添加下划线
- (void)toggleUnderline:(nullable id)sender NS_AVAILABLE_IOS(6_0);
//增加字体大小
- (void)increaseSize:(nullable id)sender NS_AVAILABLE_IOS(7_0);
//减小字体大小
- (void)decreaseSize:(nullable id)sender NS_AVAILABLE_IOS(7_0);

@end

4:UIResponder (UIResponderInputViewAdditions)分类

@class UIInputViewController;
@class UITextInputMode;
@class UITextInputAssistantItem;

@interface UIResponder (UIResponderInputViewAdditions)

//键盘输入视图(系统默认的,可以自定义)
@property (nullable, nonatomic, readonly, strong) __kindof UIView *inputView NS_AVAILABLE_IOS(3_2);
//弹出键盘时附带的视图
@property (nullable, nonatomic, readonly, strong) __kindof UIView *inputAccessoryView NS_AVAILABLE_IOS(3_2);

@property (nonnull, nonatomic, readonly, strong) UITextInputAssistantItem *inputAssistantItem NS_AVAILABLE_IOS(9_0) __WATCHOS_PROHIBITED;

//键盘输入视图控制器 IOS8以后
@property (nullable, nonatomic, readonly, strong) UIInputViewController *inputViewController NS_AVAILABLE_IOS(8_0);

//弹出键盘时附带的视图的视图控制器 IOS8以后
@property (nullable, nonatomic, readonly, strong) UIInputViewController *inputAccessoryViewController NS_AVAILABLE_IOS(8_0);

//文本输入模式
@property (nullable, nonatomic, readonly, strong) UITextInputMode *textInputMode NS_AVAILABLE_IOS(7_0);

//文本输入模式标识
@property (nullable, nonatomic, readonly, strong) NSString *textInputContextIdentifier NS_AVAILABLE_IOS(7_0);

//根据设置的标识清除指定的文本输入模式
+ (void)clearTextInputContextIdentifier:(NSString *)identifier NS_AVAILABLE_IOS(7_0);

//重新刷新键盘输入视图
- (void)reloadInputViews NS_AVAILABLE_IOS(3_2);

@end

知识点1:UITextFields和UITextView有一个inputAccessoryView的属性,当你想在键盘上展示一个自定义的view时,你就可以设置该属性。你设置的view就会自动和键盘keyboard一起显示了。

需要注意的是,你所自定义的view既不应该处在其他的视图层里,也不应该成为其他视图的子视图。其实也就是说,你所自定义的view只需要赋给属性inputAccessoryView就可以了,不要再做其他多余的操作。

我们在使用UITextView和UITextField的时候,可以通过它们的inputAccessoryView属性给输入时呼出的键盘加一个附属视图,通常是UIToolBar,用于回收键盘。

inputView就是显示键盘的view,如果重写这个view则不再弹出键盘,而是弹出自己的view.如果想实现当某一控件变为第一响应者时不弹出键盘而是弹出我们自定义的界面,那么我们就可以通过修改这个inputView来实现,比如弹出一个日期拾取器。

inputView不会随着键盘出现而出现,设置了InputView只会当UITextField或者UITextView变为第一相应者时显示出来,不会显示键盘了。设置了InputAccessoryView,它会随着键盘一起出现并且会显示在键盘的顶端。InutAccessoryView默认为nil.

5:UIResponder (ActivityContinuation)分类

// 按键输入箭头指向
UIKIT_EXTERN NSString *const UIKeyInputUpArrow         NS_AVAILABLE_IOS(7_0);
UIKIT_EXTERN NSString *const UIKeyInputDownArrow       NS_AVAILABLE_IOS(7_0);
UIKIT_EXTERN NSString *const UIKeyInputLeftArrow       NS_AVAILABLE_IOS(7_0);
UIKIT_EXTERN NSString *const UIKeyInputRightArrow      NS_AVAILABLE_IOS(7_0);
UIKIT_EXTERN NSString *const UIKeyInputEscape          NS_AVAILABLE_IOS(7_0);

//响应者类的类目:
@interface UIResponder (ActivityContinuation)
//用户活动
@property (nullable, nonatomic, strong) NSUserActivity *userActivity NS_AVAILABLE_IOS(8_0);
//更新用户活动
- (void)updateUserActivityState:(NSUserActivity *)activity NS_AVAILABLE_IOS(8_0);
//恢复用户活动
- (void)restoreUserActivityState:(NSUserActivity *)activity NS_AVAILABLE_IOS(8_0);
@end

知识点1:支持User
Activities,从iOS
8起,苹果为我们提供了一个非常棒的功能,即Handoff。使用这一功能,我们可以在一部iOS设备的某个应用上开始做一件事,然后在另一台iOS设备上继续做这件事。Handoff的基本思想是用户在一个应用里所做的任何操作都可以看作是一个Activity,一个Activity可以和一个特定iCloud用户的多台设备关联起来。在编写一个支持Handoff的应用时,会有以下三个交互事件:

  • 为将在另一台设备上继续做的事创建一个新的User
    Activity;
  • 当需要时,用新的数据更新已有的User
    Activity;
  • 把一个User Activity传递到另一台设备上。

为了支持这些交互事件,在iOS 8后,UIResponder类新增了几个方法,我们在此不讨论这几个方法的实际使用,想了解更多的话,可以参考iOS 8 Handoff
开发指南。我们在此只是简单描述一下这几个方法。

在UIResponder中,已经为我们提供了一个userActivity属性,它是一个NSUserActivity对象。因此我们在UIResponder的子类中不需要再去声明一个userActivity属性,直接使用它就行。其声明如下:

@property(nonatomic, retain) NSUserActivity *userActivity

由UIKit管理的User Activities会在适当的时间自动保存。一般情况下,我们可以重写UIResponder类的updateUserActivityState:方法来延迟添加表示User Activity的状态数据。当我们不再需要一个User Activity时,我们可以设置userActivity属性为nil。任何由UIKit管理的NSUserActivity对象,如果它没有相关的响应者,则会自动失效。

另外,多个响应者可以共享一个NSUserActivity实例。

上面提到的updateUserActivityState:是用于更新给定的User Activity的状态。其定义如下:

– (void)updateUserActivityState:(NSUserActivity *)activity

子类可以重写这个方法来按照我们的需要更新给定的User
Activity。我们需要使用NSUserActivity对象的addUserInfoEntriesFromDictionary:方法来添加表示用户Activity的状态。

在我们修改了User
Activity的状态后,如果想将其恢复到某个状态,则可以使用以下方法:

– (void)restoreUserActivityState:(NSUserActivity *)activity

子类可以重写这个方法来使用给定User
Activity的恢复响应者的状态。系统会在接收到数据时,将数据传递给application:continueUserActivity:restorationHandler:以做处理。我们重写时应该使用存储在user activity的userInfo字典中的状态数据来恢复对象。当然,我们也可以直接调用这个方法。

1
:首先查看一下关于UIResponder 的定义 NS_CLASS_AVAILABLE_IOS(2_0)
@interface UIResponder : NSObject // 响应链中负责传…

一、事件分类

事件是发送到应用程序用于通知用户操作的对象。
在iOS中,事件可以采取多种形式:多点触摸事件,运动事件和用于控制多媒体的事件。
这最后一种类型的事件被称为遥控事件或者远程控制事件,因为它可以源自外部附件。而在我们开发过程中最常用的就是多点触摸事件。

yzc579亚洲城官网 3Event
in iOS

简介

  1. Responder Chain for Action Messages

    macOS Cocoa Application,macOS开发使用的到的响应者知识。

  2. Responder Chain for Event Messages

    UIApplication,iOS开发使用的到的响应者知识,这里重点介绍。应用程序接收和处理事件使用响应对象。接受对象是任何实例UIResponder类和普通子类,包括UIView,UIViewController,UIApplication。UIKit自动管理大多数相应相关的行为,包括事件是如何从一个响应者传送到下一个响应者。当然,您可以修改默认行为以更改事件在应用程序中的传递方式。

    UIKit将大部分事件传递给最恰当的响应者对象。如果对象不处理该事件,UIKit转发给在活跃的响应者链条中的下一个响应者,这是一个动态的应用程序的响应对象的配置。因为它是动态的,在你的应用程序中没有单一的响应链。然而,它很容易确定在链中的下一个响应器,因为事件总是从特定的响应对象流到更一般的响应对象。例如,一个视图的下一个响应者要么是它的父视图要么是管理它的视图控制器。事件一直向响应链流,直到它们被处理为止。

    分析:UIKit帮我们做了默认的事件传递,然而我们可以修改默认行为,比如,nextResponder属性、在touchesBegan:
    withEvent:中修改。

二、事件传递与响应链

当您设计应用程式时,可能需要动态响应事件。
例如,触摸可以发生在屏幕上的许多不同对象中,并且您必须决定您想要那个对象响应事件,并且理解该对象如何接收该事件。

当用户生成的事件发生时,UIKit创建一个包含处理事件所需信息的事件对象。
然后它将事件对象放置在活动应用程序的事件队列中。
对于触摸事件,该对象是在UIEvent对象中打包的一组触摸。
对于运动事件,事件对象因您使用的框架和您感兴趣的运动事件类型而异。

事件沿着特定路径传递,直到它被传递到可以处理它的对象。
首先,单例UIApplication对象从队列的顶部获取一个事件并分发处理。
通常,它将事件发送到应用程序的key
window对象,该对象将事件传递到初始对象(initial object)进行处理。
初始对象取决于事件的类型。

  • 触摸事件:对于触摸事件,窗口对象首先尝试将事件传递到发生触摸的视图。
    该视图称为命中测试视图(hit-test view)。 找到命中测试视图(hit-test
    view)的过程称为命中测试(hit-testing),这在Hit-Testing返回触摸发生的视图中描述。

  • 运动和遥控事件:对于这些事件,窗口对象将摇动或远程控制事件发送到第一响应者以进行处理。
    第一响应者在响应者链由响应者对象组成中描述。

这些事件路径的最终目标是找到一个可以处理和响应事件的对象。
因此,UIKit首先将事件发送到最适合处理事件的对象。
对于触摸事件,该对象是命中测试视图(hit-test
view),对于其他事件,该对象是第一个响应者。
以下部分更详细地说明命中测试视图(hit-test
view)和第一响应者对象是如何确定的。

iOS使用命中测试(hit-testing)来查找被触摸的视图。
命中测试(hit-testing)涉及检查触摸是否在所有相关视图对象的边界内。
如果是,它会递归检查视图的所有子视图。视图层级中包含触摸点的最低的视图成为命中测试视图(hit-test
view) 。 iOS确定命中测试视图(hit-test
view)后,它会将触摸事件传递到该视图进行处理。

举例说明,假设用户触摸下图中的View E。
iOS通过按照此顺序检查子视图来查找命中测试视图(hit-test view):

  1. 触摸在View A的边界内,因此它检查子视图View B和View C.

  2. 触摸不在View B的界限内,但它在View C的界限内,因此它检查子视图View
    D和View E.

  3. 触摸不在View D的界限内,但它在View E的界限内。

    View
    E是视图层级中包含触摸的最低的视图,因此它成为命中测试视图(hit-test
    view)。

    yzc579亚洲城官网 4Hit-testing
    returns the subview that was touched

hitTest:withEvent:方法为给定的CGPoint和UIEvent返回一个点击测试视图(hit-test
view)。hitTest:withEvent:方法首先调用pointInside:withEvent:方法。
如果传递到hitTest:withEvent:方法的点是在视图的边界内,pointInside:withEvent:返回YES。然后,在每个返回YES的子视图上递归调用hitTest:withEvent:方法

如果传递到hitTest:withEvent:方法的点不在视图的边界内,第一次调用pointInside:withEvent:方法返回
NO ,该点被忽略,hitTest:withEvent:返回nil 。
如果子视图返回NO,则视图层级结构的这个整个分支将被忽略,因为如果触摸没有发生在该子视图中,则它也不会出现在该子视图的任何子视图中。这意味着在子视图内而在父视图之外的任何点都不能接受点击事件,因为触摸点必须在父视图和子视图边界内。如果子视图的clipsToBounds属性设置为NO,则可能出现此问题。见示例将事件传递给子视图

注:触摸对象为其生命周期而关联到其命中测试视图(hit-test
view),即使触摸稍后移动到视图之外。

命中测试视图(hit-test view)被给予首先处理触摸事件的机会。
如果命中测试视图(hit-test
view)无法处理的事件,事件沿着响应者链向上传播(如响应者链由响应者对象组成中描述),直到系统找到一个可以处理它的对象。

许多类型的事件依赖于为事件传递的响应者链。
响应链是一系列被链接起来的响应对象。
它从第一响应者开始,到程序对象(UIApplication object)结束。
如果第一响应者不能处理事件,它转发事件到响应者链中的下一个响应者。

响应者对象是一个可以响应和处理事件的对象。
UIResponder类是所有响应者对象的基类,它不仅为事件处理定义编程接口,也为常见响应者行为定义编程接口。UIApplication,
UIViewController和UIView类的实例都是响应者(responder),这意味着所有的视图和大多数控制器对象都是响应者。
注意核心动画层不是响应者。

第一个响应者被指定为第一个接收事件。 通常,第一响应者是视图对象。
一个对象通过做两件事情成为第一个响应者:

  1. 重写canBecomeFirstResponder方法返回YES。
  2. 接收becomeFirstResponder消息。
    如果需要,对象可以向自身发送此消息。

注:请确保您的应用程序在指派一个对象成为第一个响应者之前已经建立了对象图(has
established its object
graph,个人感觉应该理解为对象已经被渲染完成
)。
例如,您通常在重写的viewDidAppear:方法中调用becomeFirstResponder方法。
如果您尝试在viewWillAppear:中指派第一响应者,你的对象图尚未建立(object
graph is not yet
established,个人理解为对象渲染尚未完成
),所以becomeFirstResponder方法返回
NO 。

事件不是唯一依赖响应者链的对象,响应者链用于以下所有情况:

  • 触摸事件(Touch events):如果命中测试视图(hit-test
    view)不能够处理触摸事件,事件以命中测试视图(hit-test
    view)为起点沿着响应者链向上传递。
  • 运动事件(Motion
    events):为了使用UIKit处理摇动动作事件,第一响应者必须实现UIResponder类的motionBegan:withEvent:motionEnded:withEvent:的方法。
  • 遥控事件(Remote control
    event):为了处理遥控事件,第一响应者必须实现UIResponder类的remoteControlReceivedWithEvent:方法。
  • 动作消息(Action
    messages):当用户操作一个控制对象,例如一个按钮或者开关,并且动作方法(action
    method)的目标是nil,则消息以控制视图为起点沿着响应者链传递。参阅示例:将事件传递给父视图
  • 编辑菜单消息(Editing-menu
    messages):当用户点击编辑菜单中的命令,iOS使用响应者链找到实现了必要方法的对象(如cut:
    copy:paste: )。 想了解更多信息,请参阅显示和管理编辑菜单 。

  • 文本编辑(Text editing):当用户点击text field或text
    view,该视图自动成为第一个响应者。 默认情况下,虚拟键盘出现,text
    field或text
    view成为编辑的焦点。您可以显示自定义输入视图,而不是键盘。
    您还可以向任何响应者对象添加自定义输入视图。
    想了解更多信息,请参阅自定义数据输入视图 。

UIKit自动设置用户点击的text field或text view为第一个响应者;
应用程序必须使用becomeFirstResponder方法显式设置所有其他对象为第一响应者。

如果初始对象(命中测试视图或第一个响应者)不处理事件,UIKit将事件传递给链中的下一个响应者。
每个响应者决定是否它要处理事件或通过调用其nextRsponder方法传递给它自己的下一个响应者。这种处理持续进行,直到一个响应者对象处理事件或有没有更多的响应者。

当iOS检测到事件并将其传递给初始对象时,响应者链序列开始。
初始视图拥有第一机会处理事件。下图显示了两个不同配置应用程序的两个不同事件传递路径。应用程序的事件传递路径取决于其特定结构,但所有事件传递路径都遵循相同的探视程序。

yzc579亚洲城官网 5The
responder chain on iOS

对于左侧的应用程序,事件遵循以下路径:

  1. 初始视图试图处理该事件或消息。如果它不能处理这个事件,它将事件传递到其父视图
    ,因为初始视图在它的视图控制器的视图层次中不是最顶部的视图。
  2. 父视图尝试处理该事件。如果父视图不能处理事件,它将事件传递到其超级视图,因为它仍然不是视图层次中最顶部的视图。
  3. 视图控制器的视图层次中最顶层视图尝试处理该事件。如果最顶层的视图不能处理事件,它将事件传递到它的视图控制器。
  4. 视图控制器尝试处理该事件,如果不能,将事件传递到窗口。
  5. 如果窗口对象不能处理该事件,传递事件到单例应用程序对象。
  6. 如果应用程序对象不能处理这个事件,它丢弃该事件。

右侧的应用程序遵循稍微不同的路径,但所有事件传递路径遵循以下探视程序:

  1. 视图在其视图控制器的视图层次结构上向上传递事件,直到它到达最顶层视图。

  2. 最顶层视图将事件传递到其视图控制器。

  3. 视图控制器将事件传递到其最顶层视图的父视图。

    重复步骤1-3,直到事件到达根视图控制器。

  4. 根视图控制器将事件传递到窗口对象。

  5. 窗口将事件传递给应用程序对象。

重要提示:如果您实现一个自定义视图来处理遥控事件,动作消息,UIKit的摇移动事件,或编辑菜单消息,不要直接转发事件或消息到nextResponder来沿响应者链向上传递。
相反,调用当前事件处理方法的超类实现,让UIKit处理响应者链的遍历。

事件类型

对于每一个事件,UIKit会制定一个第一响应者(first responder
),并且把事件首先传递给该对象。第一响应者根据以下事件类型变化而变化。

  1. Touch events:第一响应者就是触摸发生所在的控件。
  2. Press events:第一响应者就是获取焦点的响应者(see App Programming
    Guide for tvOS)
  3. Motion events:第一响应者就是你指定处理事件的响应者.
  4. Shake-motion events. 第一响应者就是你指定处理事件的响应者。
  5. Remote-control events: 第一响应者就是你指定处理事件的响应者。
  6. Editing-menu messages:第一响应者就是你指定处理事件的响应者。For
    information about the UIKit editing commands, see
    UIResponderStandardEditActions.

三、应用

从事件传递与响应者链的内容思考一些应用例子。

一个按钮的尺寸是20*20,如果要扩大按钮的点击区域(上下左右各扩大10),有以下处理方法:

  • 按钮设置image,然后按钮的size设置的比实际大一倍。
  • 在按钮上覆盖一层较大的View或者Button,设置点击事件。
  • 自定义Button,覆盖hitTest:withEvent:或者pointInside:withEvent:方法。

我们只举例说明第三种方法:

- hitTest:point withEvent:(UIEvent *)event { NSLog(@"%s", __PRETTY_FUNCTION__); if (self.userInteractionEnabled == NO || self.hidden || self.alpha <= 0.01) { return nil; } CGRect responseRect = CGRectInset(self.bounds, -10, -10); if (CGRectContainsPoint(responseRect, point)) { for (UIView *subView in [self.subviews reverseObjectEnumerator]) { CGPoint convertedPoint = [subView convertPoint:point fromView:self]; UIView *hitTestView = [subView hitTest:convertedPoint withEvent:event]; if (hitTestView) { return hitTestView; } } return self; } return nil;}

或者

- pointInside:point withEvent:(UIEvent *)event { NSLog(@"%s", __PRETTY_FUNCTION__); CGRect bounds = CGRectInset(self.bounds, -10, -10); return CGRectContainsPoint(bounds, point);}

在controller中有一个YKNoteEventHandingView,其上面再添加一个YKNoteEventHandlingButton,点击Button将事件传递到View。有以下几种做法:

  • Button的- hitTest:point withEvent:(UIEvent *)event方法返回nil,hit-test
    view为父视图

  • YKNoteEventHandingView的- hitTest:point withEvent:(UIEvent *)event方法返回self,阻止事件传递给子视图

  • 设置Button的target为nil,Button无法处理事件响应,事件沿着响应者链向上传递,传递到父视图。示例如下

#import "YKNoteEventHandingView.h"@implementation YKNoteEventHandingView//在View中写一个action方法,判断View中的Button的target为nil的时候是否会执行,若执行,则消息沿着响应者链向上传递了- ykNoteEventHandlingGreenButtonDidTouchUpInside:(UIButton *)button { NSLog(@"%s \n %@", __PRETTY_FUNCTION__, button);}@end #import "YKNoteEventHandlingButton.h"//在Button中写一个action方法,判断Button的target为nil的时候是否会执行,若执行,则消息沿着响应者链传递了@implementation YKNoteEventHandlingButton- ykNoteEventHandlingGreenButtonDidTouchUpInside:(UIButton *)button { NSLog(@"%s \n %@", __PRETTY_FUNCTION__, button);}

#import "YKNoteEventHandingViewController.h"#import "YKNoteEventHandingView.h"#import "YKNoteEventHandlingButton.h"@interface YKNoteEventHandingViewController ()@property (nonatomic, strong) YKNoteEventHandingView *yKNoteEventHandingView;@property (nonatomic, strong) YKNoteEventHandlingButton *ykNoteEventHandlingButton;@end@implementation YKNoteEventHandingViewController- viewDidLoad { [super viewDidLoad]; // Do any additional setup after loading the view. self.title = @"EventHandling"; self.view.backgroundColor = [UIColor whiteColor]; //View [self.yKNoteEventHandingView setFrame:CGRectMake(50, 100, 200, 200)]; [self.view addSubview:self.yKNoteEventHandingView]; //Button [self.ykNoteEventHandlingButton setFrame:CGRectMake(60, 60, 100, 100)]; [self.yKNoteEventHandingView addSubview:self.ykNoteEventHandlingButton];}#pragma mark - event- ykNoteEventHandlingGreenButtonDidTouchUpInside:(UIButton *)button { NSLog(@"%s \n %@", __PRETTY_FUNCTION__, button);}#pragma mark - getter- (YKNoteEventHandingView *)yKNoteEventHandingView { if (_yKNoteEventHandingView == nil) { _yKNoteEventHandingView = [[YKNoteEventHandingView alloc] init]; _yKNoteEventHandingView.backgroundColor = [UIColor redColor]; } return _yKNoteEventHandingView;}- (YKNoteEventHandlingButton *)ykNoteEventHandlingButton { if (_ykNoteEventHandlingButton == nil) { _ykNoteEventHandlingButton = [[YKNoteEventHandlingButton alloc] init]; _ykNoteEventHandlingButton.backgroundColor = [UIColor greenColor]; [_ykNoteEventHandlingButton addTarget:nil action:@selector(ykNoteEventHandlingGreenButtonDidTouchUpInside:) forControlEvents:UIControlEventTouchUpInside]; } return _ykNoteEventHandlingButton;}

 //Button的target设置为nil的时候,执行了YKNoteEventHandlingButton中的方法,说明target为nil的时候事件沿着响应者链传递了 -[YKNoteEventHandlingButton ykNoteEventHandlingGreenButtonDidTouchUpInside:] <YKNoteEventHandlingButton: 0x100224950; baseClass = UIButton; frame = (60 60; 100 100); opaque = NO; layer = <CALayer: 0x17002a1a0>> //注释掉Button中的方法。输出内容如下,说明事件沿着响应者链向上传递了。 -[YKNoteEventHandingView ykNoteEventHandlingGreenButtonDidTouchUpInside:] <YKNoteEventHandlingButton: 0x10030fe40; baseClass = UIButton; frame = (60 60; 100 100); opaque = NO; layer = <CALayer: 0x17003d520>> //注释掉Button和View中的方法。输出内容如下,说明事件沿着响应者链向上传递了。 -[YKNoteEventHandingViewController ykNoteEventHandlingGreenButtonDidTouchUpInside:] <YKNoteEventHandlingButton: 0x100402fd0; baseClass = UIButton; frame = (60 60; 100 100); opaque = NO; layer = <CALayer: 0x1740315a0>>

假设有下图所示的布局,我们希望点击view C的时候view B响应事件,而点击View
D和View E的时候正常响应。这个时候通过重写view
C的hittest可以解决这个问题,在C的hittest里面直接返回nil就行了。

yzc579亚洲城官网 4Hit-testing
returns the subview that was touched

- hitTest:point withEvent:(UIEvent *)event { NSLog(@"%s", __PRETTY_FUNCTION__); UIView *hitTestView = [super hitTest:point withEvent:event]; if (hitTestView == self) { return nil; } return hitTestView; }

如下图,banner为CollectionView中的一个楼层,CollectionViewCell中有个scrollView,scrollView中为图片,现在将cell的宽度缩小一半,设置cell和scrollview的clipsToBounds为NO,现在在右侧处滑动,scrollview中的图片显然不会滑动,因为不满足pointInside:withEvent:,这时只需要修改cell的- hitTest:point withEvent:(UIEvent *)event方法,返回scrollview即可。

yzc579亚洲城官网 7传递事件到子视图

- hitTest:point withEvent:(UIEvent *)event { UIView *hitTestView = [super hitTest:point withEvent:event]; if (hitTestView == nil) { hitTestView = self.scrollView; } return hitTestView;}

参考:

获取Touch事件的第一响应者

UIKit使用基于视图View的hit测试来确定触摸事件的发生地。具体来说,UIKit会对比触摸位置和View对象的范围(这里理解为frame)。UIView对象的hitTest:withEvent:方法会穿越视图层次寻找最深的包含指定触摸的子视图。该视图成为触摸事件的第一响应者。

对于触摸事件的响应,首先要找到能够响应该事件的对象,iOS是用hit-testing
来找到哪个视图被触摸了(hit-test
view),也就是以keyWindow为起点,hit-test
view为终点,逐级调用hitTest:withEvent。

yzc579亚洲城官网 8MJ大神的图

  1. 首先判断自己能否接收事件,以下情况视图的hitTest:withEvent:方法会返回nil,导致自身和其所有子视图不能被hit-testing发现:

    • 隐藏(hidden=YES)的视图
    • 禁止用户操作(userInteractionEnabled=NO)的视图
    • 视图View.alpha < 0.01的视图
    • 视图超出父视图的区域
  2. 再用调用pointInside:withEvent:判断触摸点是否在当前视图内:

    • 如果返回YES,那么该视图的所有子视图调用hitTest:withEvent,调用顺序由层级低到高(top->bottom)依次调用。
    • 如果返回NO,说明触摸不在控件内,那么hitTest:withEvent返回nil,该视图的所有子视图的分支全部被忽略。
  3. 在此视图的pointInside:withEvent:返回YES的情况下,并且他的所有子视图hitTest:withEvent:都返回nil,或者该视图没有子视图,那么该视图的hitTest:withEvent:返回自己。

  4. 在此视图的pointInside:withEvent:返回YES的情况下,如果子视图的hitTest:withEvent:返回非空对象,那么当前视图的hitTest:withEvent:也返回这个对象,也就是沿原路回推,最终将hit-test
    view传递给keyWindow。

- hitTest:point withEvent:(UIEvent *)event { // 1.判断下自己能否接收事件 if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) return nil; // 2.判断下点在不在当前控件上 if ([self pointInside:point withEvent:event] == NO) return nil; // 点不在当前控件 // 3.从后往前遍历自己的子控件 int count = self.subviews.count; for (int i = count - 1; i >= 0; i--) { // 获取子控件 UIView *childView = self.subviews[i]; // 把当前坐标系上的点转换成子控件上的点 CGPoint childP = [self convertPoint:point toView:childView]; UIView *fitView = [childView hitTest:childP withEvent:event]; if  { return fitView; } } // 4.如果没有比自己合适的子控件,最合适的view就是自己 return self; }// 判断下当前这个点在不在方法调用者上(我们还可以重写此方法改变寻找结果)// 使用注意点:点必须是方法调用者上的坐标系,才会判断准备//- pointInside:point withEvent:(UIEvent *)event {// [super pointInside:point withEvent:event];// return NO;//}

Tip:我们可以通过自定义- hitTest:point withEvent:(UIEvent *)event
方法忽略前两个判断做到意想不到的效果。代码如下:

// 处理不在此控件上但是在子控件上的点(超出本控件的子控件的部分) - hitTest:point withEvent:(UIEvent *)event{ // 判断下point点在不在特定子控件上(这个点明显不在此控件上) CGPoint chatP = [self convertPoint:point toView:子控件]; if ([子控件 pointInside:chatP withEvent:event] == YES) { return self.chatView; } return [super hitTest:point withEvent:event]; }

响应者链条(responder chain)

yzc579亚洲城官网 9responderChain.png

  1. 事件往上传递(nextResponder)

- touchesBegan:(NSSet<UITouch *> *)touches withEvent: (UIEvent *)event { [super touchesBegan:touches withEvent:event]; NSLog(@"%s",__func__); NSLog(@"nextResponder = %@",self.nextResponder);}
  1. 重置nextResponder

- (UIResponder *)nextResponder { return [super nextResponder]; }
  1. 我们可以通过以下两种方式中断响应者链条

    - touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {NSLog(@"nextResponder = %@",self.nextResponder);} - (UIResponder *)nextResponder {return nil;}
    

手势识别器(UIGestureRecognizer)

手势识别器是处理view中touch or press
events的一种简单方式,我们可以为一个控件添加多种识别器。它把事件和处理逻辑封装成了一种简单方便的形式。

yzc579亚洲城官网 10gestureRecognizer.png

手势识别器有两种类型:离散和连续。手势一旦被识别后,一个离散手势识别器会调用你的动作方法。一个连续的手势识别器可以多次调用你的动作方法,包括手势的开始和结束,以及每次跟踪事件的细节变化。例如,一个uipangesturerecognizer对象每当一个触摸事件的位置变化都会调用你的动作方法。界面生成器(nterface
Builder)内部包含每个标准UIKit手势识别的对象。它还包括一个自定义手势识别对象,您可以使用它作为你的自定义的UIGestureRecognizer子类。

  1. 由于UIGestureRecognizer处理的是事件相关,所以它也是遵循hitTest:
    withEvent:方法找到第一响应者。
  2. 响应者链条这里并不适用UIGestureRecognizer。如果一个控件成为第一响应者,那么如果它身上没有添加相应的手势识别器,就会查找它的父控件(而不是它的nextResponder,通过中断响应者链条测试)能不能match相应的UIGestureRecognizer。

Author

发表评论

电子邮件地址不会被公开。 必填项已用*标注