blob: f67719d7c162be3fbbe5bc0f48bd25f301dbe040 [file] [log] [blame]
// Copyright 2014 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#import "ios/web/web_state/ui/crw_web_view_scroll_view_proxy+internal.h"
#import <objc/runtime.h>
#include <memory>
#include "base/auto_reset.h"
#import "base/ios/crb_protocol_observers.h"
#include "base/mac/foundation_util.h"
#include "base/strings/sys_string_conversions.h"
#include "ios/web/common/features.h"
#import "ios/web/web_state/ui/crw_web_view_scroll_view_delegate_proxy.h"
#if !defined(__has_feature) || !__has_feature(objc_arc)
#error "This file requires ARC support."
#endif
// *Address of* this variable is used as a marker to specify that it matches any
// context.
static int gAnyContext = 0;
// A wrapper of a key-value observer. When an instance of
// CRWKeyValueObserverForwarder receives a KVO callback, it forwards the
// callback to |wrappedObserver|, but replacing the object parameter with the
// |object| given in its initializer.
//
// This is useful when creating a proxy class of an object and forwarding KVO
// against the proxy object to the underlying object, but making the KVO
// callback still look like a callback from the proxy object.
@interface CRWKeyValueObserverForwarder : NSObject
@property(nonatomic, weak) id wrappedObserver;
@property(nonatomic, weak) id object;
@property(nonatomic) NSKeyValueObservingOptions options;
@property(nonatomic) void* context;
- (instancetype)initWithWrappedObserver:(id)wrappedObserver
object:(id)object
options:(NSKeyValueObservingOptions)options
context:(void*)context
NS_DESIGNATED_INITIALIZER;
- (instancetype)init NS_UNAVAILABLE;
@end
@implementation CRWKeyValueObserverForwarder
- (instancetype)initWithWrappedObserver:(id)wrappedObserver
object:(id)object
options:(NSKeyValueObservingOptions)options
context:(void*)context {
self = [super init];
if (self) {
_wrappedObserver = wrappedObserver;
_object = object;
_options = options;
_context = context;
}
return self;
}
- (void)observeValueForKeyPath:(NSString*)keyPath
ofObject:(id)object
change:(NSDictionary*)change
context:(void*)context {
[self.wrappedObserver observeValueForKeyPath:keyPath
ofObject:self.object
change:change
context:context];
}
@end
@interface CRWWebViewScrollViewProxy () {
std::unique_ptr<UIScrollViewContentInsetAdjustmentBehavior>
_storedContentInsetAdjustmentBehavior API_AVAILABLE(ios(11.0));
std::unique_ptr<BOOL> _storedClipsToBounds;
}
// A delegate object of the UIScrollView managed by this class.
@property(nonatomic, strong, readonly)
CRWWebViewScrollViewDelegateProxy* delegateProxy;
@property(nonatomic, strong)
CRBProtocolObservers<CRWWebViewScrollViewProxyObserver>* observers;
@property(nonatomic, strong) UIScrollView* underlyingScrollView;
// This exists for compatibility with UIScrollView (see -asUIScrollView).
@property(nonatomic, weak) id<UIScrollViewDelegate> delegate;
// Wrappers of key-value observers against this instance, keyed by:
// - the key path (the outer dictionary)
// - NSValue representation of an unretained pointer to the observer (the
// inner dictionary).
//
// This dictionary must hold an *unretained* pointer to the observer, neither a
// strong or weak pointer.
// - An object should not retain its key-value observer. So it must not be a
// strong pointer.
// - The dictionary may be accessed during -dealloc of an observer. This is
// quite possible because it is common that an observer calls
// -removeObserver:forKeyPath: during its -dealloc, and this dictionary is
// accessed in -removeObserver:forKeyPath:. And a weak pointer to an object
// is not available during its -dealloc.
//
// And holding NSValue wrapping the pointer is the only way to use an unretained
// pointer as a key of a dictionary. NSMapTable supports using a weak pointer
// for its keys, but not an unretained pointer.
//
// Use of an unretained pointer here is safe because it is never dereferenced,
// and the observer must call -removeObserver:forKeyPath: before it is
// deallocated.
@property(nonatomic, strong) NSMutableDictionary<
NSString*,
NSMutableDictionary<NSValue*,
NSMutableArray<CRWKeyValueObserverForwarder*>*>*>*
keyValueObserverForwarders;
// Returns the key paths that need to be observed for UIScrollView.
+ (NSArray*)scrollViewObserverKeyPaths;
// Adds and removes key-value observers for |scrollView| needed by |proxy|.
+ (void)startObservingScrollView:(UIScrollView*)scrollView
proxy:(CRWWebViewScrollViewProxy*)proxy;
+ (void)stopObservingScrollView:(UIScrollView*)scrollView
proxy:(CRWWebViewScrollViewProxy*)proxy;
@end
// Note: An instance of this class must be safely casted to UIScrollView. See
// -asUIScrollView. To make it happen:
// - When this class defines a method with the same selector as in a method of
// UIScrollView (or its ancestor classes), its API and the behavior should
// be consistent with the UIScrollView one's.
// - Calls to UIScrollView methods not implemented in this class are forwarded
// to the underlying UIScrollView by -methodSignatureForSelector: and
// -forwardInvocation:.
@implementation CRWWebViewScrollViewProxy
- (instancetype)init {
self = [super init];
if (self) {
Protocol* protocol = @protocol(CRWWebViewScrollViewProxyObserver);
_observers =
static_cast<CRBProtocolObservers<CRWWebViewScrollViewProxyObserver>*>(
[CRBProtocolObservers observersWithProtocol:protocol]);
_delegateProxy = [[CRWWebViewScrollViewDelegateProxy alloc]
initWithScrollViewProxy:self];
_keyValueObserverForwarders = [[NSMutableDictionary alloc] init];
// Assign a placeholder UIScrollView until the actual underlying scroll view
// is set. This must be a real UIScrollView, not nil, so that:
// - The proxy preserves the values of properties assigned before the
// actual scroll view is set. These properties will then be inherited to
// the actual scroll view in -setScrollView:.
// - The proxy returns the actual default value of the property before the
// actual scroll view is set, even when the default value is non-zero
// e.g., scrollsToTop.
// - The proxy uses the actual implementation of methods defined in
// third-party categories of UIScrollView.
//
// Note that this proxy must support all methods/properties of UIScrollView,
// including those defined in third-party categories, because it provides
// -asUIScrollView method.
_underlyingScrollView = [[UIScrollView alloc] init];
// There are a few properties where the default WKWebView.scrollView has
// different values from a base UIScrollView. As _underlyingScrollView
// starts out as a base UIScrollView, the property preservation code will
// copy over these incorrect values and overwrite the default
// WKWebView.scrollView values for those properties. Instead, set those
// values to their WebKit defaults.
_underlyingScrollView.alwaysBounceVertical = YES;
_underlyingScrollView.directionalLockEnabled = YES;
[self.class startObservingScrollView:_underlyingScrollView proxy:self];
}
return self;
}
- (void)dealloc {
[self.class stopObservingScrollView:self.underlyingScrollView proxy:self];
}
- (void)addObserver:(id<CRWWebViewScrollViewProxyObserver>)observer {
[_observers addObserver:observer];
}
- (void)removeObserver:(id<CRWWebViewScrollViewProxyObserver>)observer {
[_observers removeObserver:observer];
}
- (void)setScrollView:(UIScrollView*)scrollView {
if (self.underlyingScrollView == scrollView)
return;
// Use a placeholder UIScrollView instead when nil is given. See the comment
// in -init why this is necessary.
if (!scrollView) {
scrollView = [[UIScrollView alloc] init];
}
// Clean up the delegate/observers of the old scroll view.
[self.underlyingScrollView setDelegate:nil];
[self.class stopObservingScrollView:self.underlyingScrollView proxy:self];
// Set up the delegate/observers of the new scroll view.
DCHECK(!scrollView.delegate);
scrollView.delegate = self.delegateProxy;
[self.class startObservingScrollView:scrollView proxy:self];
if (base::FeatureList::IsEnabled(
web::features::kPreserveScrollViewProperties)) {
[self preservePropertiesFromOldScrollView:self.underlyingScrollView
toNewScrollView:scrollView];
}
self.underlyingScrollView = scrollView;
// TODO(crbug.com/1023250): Restore these in
// -preservePropertiesFromOldScrollView:toNewScrollView: once the feature flag
// kPreserveScrollViewProperties is removed.
if (_storedClipsToBounds) {
scrollView.clipsToBounds = *_storedClipsToBounds;
}
// Assigns |contentInsetAdjustmentBehavior| which was set before setting the
// scroll view.
if (_storedContentInsetAdjustmentBehavior) {
self.underlyingScrollView.contentInsetAdjustmentBehavior =
*_storedContentInsetAdjustmentBehavior;
}
[_observers webViewScrollViewProxyDidSetScrollView:self];
}
// Preserves properties of the underlying scroll view when it changes from
// |oldScrollView| to |newScrollView|.
//
// This is necessary to avoid losing properties set against the proxy when the
// underlying scroll view is reset.
- (void)preservePropertiesFromOldScrollView:(UIScrollView*)oldScrollView
toNewScrollView:(UIScrollView*)newScrollView {
// This method should preserve all properties of UIScrollView and its
// ancestor classes (not limited to properties explicitly declared in
// CRWWebViewScrollViewProxy) which:
// - is a readwrite property
// - AND is supposed to be modified directly, considering it's a scroll
// view of a web view. e.g., |frame| and |subviews| do not meet this
// condition because they are managed by the web view. |backgroundColor|
// is also managed by WKWebView to match the page's background color, and
// should not be set directly (see crbug.com/1078790).
//
// Properties not explicitly declared in CRWWebViewScrollViewProxy can still
// be accessed via -asUIScrollView, so they should be preserved as well.
// UIScrollView properties.
newScrollView.scrollEnabled = oldScrollView.scrollEnabled;
newScrollView.directionalLockEnabled = oldScrollView.directionalLockEnabled;
newScrollView.pagingEnabled = oldScrollView.pagingEnabled;
newScrollView.scrollsToTop = oldScrollView.scrollsToTop;
newScrollView.bounces = oldScrollView.bounces;
newScrollView.alwaysBounceVertical = oldScrollView.alwaysBounceVertical;
newScrollView.alwaysBounceHorizontal = oldScrollView.alwaysBounceHorizontal;
newScrollView.showsHorizontalScrollIndicator =
oldScrollView.showsHorizontalScrollIndicator;
newScrollView.showsVerticalScrollIndicator =
oldScrollView.showsVerticalScrollIndicator;
newScrollView.canCancelContentTouches = oldScrollView.canCancelContentTouches;
newScrollView.delaysContentTouches = oldScrollView.delaysContentTouches;
newScrollView.keyboardDismissMode = oldScrollView.keyboardDismissMode;
newScrollView.indexDisplayMode = oldScrollView.indexDisplayMode;
newScrollView.indicatorStyle = oldScrollView.indicatorStyle;
// UIView properties.
newScrollView.hidden = oldScrollView.hidden;
newScrollView.alpha = oldScrollView.alpha;
newScrollView.opaque = oldScrollView.opaque;
newScrollView.tintColor = oldScrollView.tintColor;
newScrollView.tintAdjustmentMode = oldScrollView.tintAdjustmentMode;
newScrollView.clearsContextBeforeDrawing =
oldScrollView.clearsContextBeforeDrawing;
newScrollView.maskView = oldScrollView.maskView;
newScrollView.userInteractionEnabled = oldScrollView.userInteractionEnabled;
newScrollView.multipleTouchEnabled = oldScrollView.multipleTouchEnabled;
newScrollView.exclusiveTouch = oldScrollView.exclusiveTouch;
}
- (BOOL)clipsToBounds {
return self.underlyingScrollView.clipsToBounds;
}
- (void)setClipsToBounds:(BOOL)clipsToBounds {
_storedClipsToBounds = std::make_unique<BOOL>(clipsToBounds);
self.underlyingScrollView.clipsToBounds = clipsToBounds;
}
- (UIScrollViewContentInsetAdjustmentBehavior)contentInsetAdjustmentBehavior
API_AVAILABLE(ios(11.0)) {
return [self.underlyingScrollView contentInsetAdjustmentBehavior];
}
- (void)setContentInsetAdjustmentBehavior:
(UIScrollViewContentInsetAdjustmentBehavior)contentInsetAdjustmentBehavior
API_AVAILABLE(ios(11.0)) {
[self.underlyingScrollView
setContentInsetAdjustmentBehavior:contentInsetAdjustmentBehavior];
_storedContentInsetAdjustmentBehavior =
std::make_unique<UIScrollViewContentInsetAdjustmentBehavior>(
contentInsetAdjustmentBehavior);
}
- (NSArray<__kindof UIView*>*)subviews {
return [self.underlyingScrollView subviews];
}
#pragma mark -
+ (NSArray*)scrollViewObserverKeyPaths {
return @[ @"frame", @"contentSize", @"contentInset" ];
}
+ (void)startObservingScrollView:(UIScrollView*)scrollView
proxy:(CRWWebViewScrollViewProxy*)proxy {
// Add observations by |proxy|.
for (NSString* keyPath in [proxy.class scrollViewObserverKeyPaths]) {
[scrollView addObserver:proxy forKeyPath:keyPath options:0 context:nil];
}
// Restore observers which were added to the past underlying scroll views.
for (NSString* keyPath in proxy.keyValueObserverForwarders) {
NSMutableDictionary<NSValue*,
NSMutableArray<CRWKeyValueObserverForwarder*>*>* map =
proxy.keyValueObserverForwarders[keyPath];
for (NSValue* observerValue in map) {
for (CRWKeyValueObserverForwarder* observerForwarder in
map[observerValue]) {
[scrollView addObserver:observerForwarder
forKeyPath:keyPath
options:observerForwarder.options
context:observerForwarder.context];
}
}
}
}
+ (void)stopObservingScrollView:(UIScrollView*)scrollView
proxy:(CRWWebViewScrollViewProxy*)proxy {
// Remove observations by |self|.
for (NSString* keyPath in [proxy.class scrollViewObserverKeyPaths]) {
[scrollView removeObserver:proxy forKeyPath:keyPath];
}
// Remove observations added externally.
for (NSString* keyPath in proxy.keyValueObserverForwarders) {
NSMutableDictionary<NSValue*,
NSMutableArray<CRWKeyValueObserverForwarder*>*>* map =
proxy.keyValueObserverForwarders[keyPath];
for (NSValue* observerValue in map) {
for (CRWKeyValueObserverForwarder* observerForwarder in
map[observerValue]) {
[scrollView removeObserver:observerForwarder
forKeyPath:keyPath
context:observerForwarder.context];
}
}
}
}
- (void)observeValueForKeyPath:(NSString*)keyPath
ofObject:(id)object
change:(NSDictionary*)change
context:(void*)context {
DCHECK_EQ(object, self.underlyingScrollView);
if ([keyPath isEqualToString:@"frame"])
[_observers webViewScrollViewFrameDidChange:self];
if ([keyPath isEqualToString:@"contentSize"])
[_observers webViewScrollViewDidResetContentSize:self];
if ([keyPath isEqualToString:@"contentInset"])
[_observers webViewScrollViewDidResetContentInset:self];
}
- (UIScrollView*)asUIScrollView {
// See the comment of @implementation of this class for why this should be
// safe.
return (UIScrollView*)self;
}
#pragma mark - Forwards unimplemented UIScrollView methods
- (NSMethodSignature*)methodSignatureForSelector:(SEL)sel {
// Called when this proxy is accessed through -asUIScrollView and the method
// is not implemented in this class.
return [self.underlyingScrollView methodSignatureForSelector:sel];
}
- (void)forwardInvocation:(NSInvocation*)invocation {
// Called when this proxy is accessed through -asUIScrollView and the method
// is not implemented in this class. Forwards the invocation to the undelrying
// scroll view.
[invocation invokeWithTarget:self.underlyingScrollView];
}
#pragma mark - NSObject
- (BOOL)isKindOfClass:(Class)aClass {
// Pretend self to be a kind of UIScrollView.
return
[UIScrollView isSubclassOfClass:aClass] || [super isKindOfClass:aClass];
}
- (BOOL)respondsToSelector:(SEL)aSelector {
// Respond to both of UIScrollView methods and its own methods.
return [UIScrollView instancesRespondToSelector:aSelector] ||
[super respondsToSelector:aSelector];
}
#pragma mark - KVO
- (void)addObserver:(NSObject*)observer
forKeyPath:(NSString*)keyPath
options:(NSKeyValueObservingOptions)options
context:(nullable void*)context {
// KVO against CRWWebViewScrollViewProxy works as KVO against the underlying
// scroll view, except that |object| parameter of the notification points to
// CRWWebViewScrollViewProxy, not the undelying scroll view. This is achieved
// by CRWKeyValueObserverForwarder.
NSMutableDictionary<NSValue*, NSMutableArray<CRWKeyValueObserverForwarder*>*>*
map = _keyValueObserverForwarders[keyPath];
if (!map) {
map = [[NSMutableDictionary alloc] init];
_keyValueObserverForwarders[keyPath] = map;
}
// See the comment of the definition of _keyValueObserverForwarders for why
// NSValue with an unretained pointer is used here.
NSValue* observerValue = [NSValue valueWithNonretainedObject:observer];
NSMutableArray<CRWKeyValueObserverForwarder*>* observerForwarders =
map[observerValue];
if (!observerForwarders) {
observerForwarders = [[NSMutableArray alloc] init];
map[observerValue] = observerForwarders;
}
CRWKeyValueObserverForwarder* observerForwarder =
[[CRWKeyValueObserverForwarder alloc] initWithWrappedObserver:observer
object:self
options:options
context:context];
[observerForwarders addObject:observerForwarder];
[self.underlyingScrollView addObserver:observerForwarder
forKeyPath:keyPath
options:options
context:context];
}
- (void)removeObserver:(NSObject*)observer forKeyPath:(NSString*)keyPath {
[self removeObserver:observer forKeyPath:keyPath context:&gAnyContext];
}
- (void)removeObserver:(NSObject*)observer
forKeyPath:(NSString*)keyPath
context:(void*)context {
NSMutableDictionary<NSValue*, NSMutableArray<CRWKeyValueObserverForwarder*>*>*
map = _keyValueObserverForwarders[keyPath];
// See the comment of the definition of _keyValueObserverForwarders for why
// NSValue with an unretained pointer is used here.
NSValue* observerValue = [NSValue valueWithNonretainedObject:observer];
NSMutableArray<CRWKeyValueObserverForwarder*>* observerForwarders =
map[observerValue];
// It is technically allowed to call -addObserver:forKeypath:options:context:
// multiple times with the same |observer| and same |keyPath|. And
// -removeObserver:forKeyPath:context: (and -removeObserver:forKeyPath:)
// removes the *last* observation matching the condition. This matches the
// (undocumented) behavior of the built-in KVO.
NSInteger i = static_cast<NSInteger>(observerForwarders.count) - 1;
for (; i >= 0; --i) {
if (context == &gAnyContext || observerForwarders[i].context == context) {
break;
}
}
// DCHECK on an attempt to remove an observer which is not registered. This
// behavior is inconsistent with the behavior of this method in NSObject
// (which throws an exception in this case). But Chromium code is not allowed
// to throw exceptions.
DCHECK_GE(i, 0) << base::SysNSStringToUTF8(
context == &gAnyContext
? [NSString
stringWithFormat:
@"Cannot remove an observer %@ for the key path \"%@\" "
@"from %@ because it is not registered as an observer.",
observer, keyPath, self]
: [NSString
stringWithFormat:
@"Cannot remove an observer %@ for the key path \"%@\" "
@"with context %p from %@ because it is not registered as "
@"an observer.",
observer, keyPath, context, self]);
[self.underlyingScrollView removeObserver:observerForwarders[i]
forKeyPath:keyPath];
[observerForwarders removeObjectAtIndex:i];
}
@end