chromium/ios/web/web_state/ui/crw_web_view_scroll_view_proxy.mm

// Copyright 2014 The Chromium Authors
// 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>
#import <memory>

#import "base/apple/foundation_util.h"
#import "base/auto_reset.h"
#import "base/ios/crb_protocol_observers.h"
#import "base/strings/sys_string_conversions.h"
#import "ios/web/common/features.h"
#import "ios/web/web_state/ui/crw_web_view_scroll_view_delegate_proxy.h"

// *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 ()

// 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];

  [self preservePropertiesFromOldScrollView:self.underlyingScrollView
                            toNewScrollView:scrollView];

  self.underlyingScrollView = scrollView;

  [_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.
  if (base::FeatureList::IsEnabled(
          web::features::kScrollViewProxyScrollEnabledWorkaround)) {
    if (newScrollView.scrollEnabled != oldScrollView.scrollEnabled) {
      // Don't update scrollEnabled if it is the same value as it creates issues
      // with clobbering state in WebKit, since the getter and setter in WebKit
      // are not symmetric. The setter sets state about whether the WKWebView
      // embedder wants to disable scrolling, while the getter and used value
      // also account for whether the main-frame is scrollable (e.g., due to the
      // size of its content relative to the viewport). See crbug.com/1375837.
      newScrollView.scrollEnabled = oldScrollView.scrollEnabled;
    }
  } else {
    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;
  if (newScrollView.clipsToBounds != oldScrollView.clipsToBounds) {
    newScrollView.clipsToBounds = oldScrollView.clipsToBounds;
  }
  if (newScrollView.contentInsetAdjustmentBehavior !=
      oldScrollView.contentInsetAdjustmentBehavior) {
    newScrollView.contentInsetAdjustmentBehavior =
        oldScrollView.contentInsetAdjustmentBehavior;
  }
}

- (BOOL)clipsToBounds {
  return self.underlyingScrollView.clipsToBounds;
}

- (void)setClipsToBounds:(BOOL)clipsToBounds {
  self.underlyingScrollView.clipsToBounds = clipsToBounds;
}

- (UIScrollViewContentInsetAdjustmentBehavior)contentInsetAdjustmentBehavior {
  return [self.underlyingScrollView contentInsetAdjustmentBehavior];
}

- (void)setContentInsetAdjustmentBehavior:
    (UIScrollViewContentInsetAdjustmentBehavior)contentInsetAdjustmentBehavior {
  [self.underlyingScrollView
      setContentInsetAdjustmentBehavior:contentInsetAdjustmentBehavior];
}

- (NSArray<__kindof UIView*>*)subviews {
  return [self.underlyingScrollView subviews];
}

#pragma mark -

+ (NSArray*)scrollViewObserverKeyPaths {
  if (base::FeatureList::IsEnabled(web::features::kSmoothScrollingDefault)) {
    return @[ @"frame", @"contentSize", @"contentInset" ];
  } else {
    return @[ @"contentSize" ];
  }
}

+ (void)startObservingScrollView:(UIScrollView*)scrollView
                           proxy:(CRWWebViewScrollViewProxy*)proxy {
  // Add observations by `proxy`.
  for (NSString* keyPath in [proxy.class scrollViewObserverKeyPaths]) {
    [scrollView
        addObserver:proxy
         forKeyPath:keyPath
            options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld
            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 (base::FeatureList::IsEnabled(web::features::kSmoothScrollingDefault)) {
    if ([keyPath isEqualToString:@"frame"]) {
      [_observers webViewScrollViewFrameDidChange:self];
    }
    if ([keyPath isEqualToString:@"contentInset"]) {
      [_observers webViewScrollViewDidResetContentInset:self];
    }
  }
  if ([keyPath isEqualToString:@"contentSize"]) {
    if (!base::FeatureList::IsEnabled(web::features::kSmoothScrollingDefault)) {
      NSValue* oldValue =
          base::apple::ObjCCast<NSValue>(change[NSKeyValueChangeOldKey]);
      NSValue* newValue =
          base::apple::ObjCCast<NSValue>(change[NSKeyValueChangeNewKey]);
      // If the value is unchanged -- if the old and new values are equal --
      // then return without notifying observers.
      if (oldValue && newValue && [newValue isEqualToValue:oldValue]) {
        return;
      }
    }
    [_observers webViewScrollViewDidResetContentSize: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