chromium/ios/chrome/browser/contextual_panel/ui/contextual_sheet_view_controller.mm

// Copyright 2024 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/chrome/browser/contextual_panel/ui/contextual_sheet_view_controller.h"

#import "base/metrics/histogram_functions.h"
#import "ios/chrome/browser/contextual_panel/ui/trait_collection_change_delegate.h"
#import "ios/chrome/browser/contextual_panel/utils/contextual_panel_metrics.h"
#import "ios/chrome/browser/shared/public/commands/contextual_sheet_commands.h"
#import "ios/chrome/common/ui/colors/semantic_color_names.h"
#import "ios/chrome/common/ui/util/constraints_ui_util.h"

namespace {

// Height for the default resting place of a medium detent sheet.
const int kDefaultMediumDetentHeight = 450;

// Top margin for the resting place of a large detent sheet.
const int kLargeDetentTopMargin = 50;

// Duration for the animation of the sheet's height.
const CGFloat kHeightAnimationDuration = 0.3;

// Radius of the 2 top corners on the sheet.
const CGFloat kTopCornerRadius = 10;

}  // namespace

@implementation ContextualSheetViewController {
  // Gesture recognizer used to expand and dismiss the sheet.
  UIPanGestureRecognizer* _panGestureRecognizer;

  // Constraint for the height of the sheet that changes
  // as the sheet expands.
  NSLayoutConstraint* _heightConstraint;

  // Stores the initial value of the heightConstraint when the pan gesture
  // starts for use in calculation.
  CGFloat _initialHeightConstraintConstant;

  // The height of the sheet's content.
  CGFloat _contentHeight;

  // Constraints for the width of the sheet. The second constraint constrains
  // the sheet to a portion of its parent's width and is used in compact height.
  NSLayoutConstraint* _widthConstraint;
  NSLayoutConstraint* _compactHeightWidthConstraint;
}

- (void)viewDidLoad {
  self.view.translatesAutoresizingMaskIntoConstraints = NO;

  _panGestureRecognizer = [[UIPanGestureRecognizer alloc]
      initWithTarget:self
              action:@selector(handlePanGesture:)];
  [self.view addGestureRecognizer:_panGestureRecognizer];

  self.view.layer.cornerRadius = kTopCornerRadius;
  self.view.layer.maskedCorners =
      kCALayerMinXMinYCorner | kCALayerMaxXMinYCorner;
  self.view.clipsToBounds = YES;
}

- (void)viewWillAppear:(BOOL)animated {
  [super viewWillAppear:animated];

  [[NSNotificationCenter defaultCenter]
      addObserver:self
         selector:@selector(handleKeyboardWillShow:)
             name:UIKeyboardWillShowNotification
           object:nil];
}

- (void)didMoveToParentViewController:(UIViewController*)parent {
  if (!parent) {
    _heightConstraint = nil;
    return;
  }

  // Position the view inside its parent.
  [NSLayoutConstraint activateConstraints:@[
    [self.view.bottomAnchor
        constraintEqualToAnchor:self.view.superview.bottomAnchor],
    [self.view.centerXAnchor
        constraintEqualToAnchor:self.view.superview.centerXAnchor],
  ]];

  _widthConstraint = [self.view.widthAnchor
      constraintEqualToAnchor:self.view.superview.widthAnchor];
  _widthConstraint.active =
      self.traitCollection.verticalSizeClass != UIUserInterfaceSizeClassCompact;

  _compactHeightWidthConstraint = [self.view.widthAnchor
      constraintEqualToAnchor:self.view.superview.widthAnchor
                   multiplier:0.66];
  _compactHeightWidthConstraint.active =
      self.traitCollection.verticalSizeClass == UIUserInterfaceSizeClassCompact;

  CGFloat initialHeight =
      (self.traitCollection.verticalSizeClass ==
       UIUserInterfaceSizeClassCompact)
          ? self.view.superview.frame.size.height - kLargeDetentTopMargin
          : [self mediumDetentHeight];
  _heightConstraint =
      [self.view.heightAnchor constraintEqualToConstant:initialHeight];
  _heightConstraint.active = YES;
}

- (void)traitCollectionDidChange:(UITraitCollection*)previousTraitCollection {
  [super traitCollectionDidChange:previousTraitCollection];

  [self.traitCollectionDelegate traitCollectionDidChangeForViewController:self];

  // It's possible that changing the trait collection removes this view from
  // the view hierarchy, so only do the remaining updates if it's still here.
  if (!self.view.superview) {
    return;
  }

  [self
      animateHeightConstraintToConstant:[self
                                            restingHeightWithSheetVelocity:0]];

  _widthConstraint.active =
      self.traitCollection.verticalSizeClass != UIUserInterfaceSizeClassCompact;
  _compactHeightWidthConstraint.active =
      self.traitCollection.verticalSizeClass == UIUserInterfaceSizeClassCompact;
}

- (BOOL)accessibilityPerformEscape {
  [self closeSheet];
  return YES;
}

// Returns the calculated detent of the medium height sheet. If the content
// height is less than the default medium detent, use that instead of the
// default.
- (CGFloat)mediumDetentHeight {
  if (_contentHeight <= 0) {
    return kDefaultMediumDetentHeight;
  }
  return MIN(_contentHeight, kDefaultMediumDetentHeight);
}

// If the sheet is short because the medium detent's size is lower than default,
// then don't allow expansion to large detent. Compact height devices only ever
// show the large detent, so it should always be allowed there.
- (BOOL)shouldAllowLargeDetent {
  return self.traitCollection.verticalSizeClass ==
             UIUserInterfaceSizeClassCompact ||
         [self mediumDetentHeight] == kDefaultMediumDetentHeight;
}

// Returns the height the sheet should rest at if released at the current
// position and velocity. Velocity should be positive if the sheet is moving
// up/getting larger. Returns 0 to indicate the sheet should be closed.
- (CGFloat)restingHeightWithSheetVelocity:(CGFloat)velocity {
  CGFloat superviewHeight = self.view.superview.frame.size.height;
  CGFloat currentSheetHeight = _heightConstraint.constant;

  // Estimate the final resting point pretending the sheet will decelerate over
  // 1 second. This is just a simple heuristic for what should feel reasonable
  // to the user. It is not taken from any UIKit deceleration code. Then the
  // resulting formula comes from the physics formula for distance traveled with
  // constant acceleration and known initial velocity and time.
  CGFloat estimatedFinalRestingHeight = currentSheetHeight + 0.5 * velocity;

  NSMutableArray<NSNumber*>* detents =
      [[NSMutableArray alloc] initWithArray:@[ @0 ]];
  // Only regular height devices support medium detent.
  if (self.traitCollection.verticalSizeClass !=
      UIUserInterfaceSizeClassCompact) {
    [detents addObject:[NSNumber numberWithDouble:[self mediumDetentHeight]]];
  }
  if ([self shouldAllowLargeDetent]) {
    [detents addObject:[NSNumber numberWithDouble:superviewHeight -
                                                  kLargeDetentTopMargin]];
  }

  // Find the detents the current height is between.
  if (currentSheetHeight < [detents[0] doubleValue]) {
    return [detents[0] doubleValue];
  }

  for (NSUInteger index = 0; index < detents.count - 1; index++) {
    if (currentSheetHeight < [detents[index + 1] doubleValue]) {
      // If the estimated resting height is less than halfway to the next
      // detent, then the resting height is the current detent. Otherwise, it's
      // the next detent.
      if (estimatedFinalRestingHeight <
          ([detents[index] doubleValue] + [detents[index + 1] doubleValue]) /
              2) {
        return [detents[index] doubleValue];
      } else {
        return [detents[index + 1] doubleValue];
      }
    }
  }

  // Detents is initialized with 0, so there's always at least one option.
  return [detents[detents.count - 1] doubleValue];
}

- (void)handlePanGesture:(UIPanGestureRecognizer*)sender {
  if (sender.state == UIGestureRecognizerStateBegan) {
    _initialHeightConstraintConstant = _heightConstraint.constant;
  }

  CGFloat translation = [sender translationInView:self.view].y;
  // According to the gesture recognizer, positive velocity means gesture moving
  // down the screen (towards positive y), but it's easier to think about
  // positive velocity meaning sheet getting taller.
  CGFloat sheetVelocity = -[sender velocityInView:self.view].y;

  _heightConstraint.constant = _initialHeightConstraintConstant - translation;

  if (sender.state == UIGestureRecognizerStateEnded) {
    CGFloat newHeight = [self restingHeightWithSheetVelocity:sheetVelocity];
    if (newHeight == 0) {
      [self closeSheet];
    } else {
      [self animateHeightConstraintToConstant:newHeight];
    }
  }
}

- (void)animateAppearance {
  CGFloat initialHeight = _heightConstraint.constant;

  _heightConstraint.constant = 0;
  // Make sure the view is laid out offscreen to prepare for the animation in.
  [self.view.superview layoutIfNeeded];

  [self animateHeightConstraintToConstant:initialHeight];
}

- (void)animateHeightConstraintToConstant:(CGFloat)constant {
  __weak __typeof(self) weakSelf = self;
  [UIView animateWithDuration:kHeightAnimationDuration
                        delay:0
                      options:UIViewAnimationOptionCurveEaseOut
                   animations:^{
                     [weakSelf
                         blockForAnimatingHeightConstraintToConstant:constant];
                   }
                   completion:nil];
}

- (void)blockForAnimatingHeightConstraintToConstant:(CGFloat)constant {
  _heightConstraint.constant = constant;
  [self.view.superview layoutIfNeeded];
}

- (void)closeSheet {
  base::UmaHistogramEnumeration("IOS.ContextualPanel.DismissedReason",
                                ContextualPanelDismissedReason::UserDismissed);
  [self.contextualSheetHandler closeContextualSheet];
}

#pragma mark - ContextualSheetDisplayController

- (void)setContentHeight:(CGFloat)height {
  _contentHeight = height;

  CGFloat newHeight = [self restingHeightWithSheetVelocity:0];
  // This should not close the sheet if the current height is short and the new
  // contentHeight is tall.
  if (newHeight > 0) {
    _heightConstraint.constant = newHeight;
  }
}

#pragma mark - Keyboard notifications

- (void)handleKeyboardWillShow:(NSNotification*)notification {
  base::UmaHistogramEnumeration("IOS.ContextualPanel.DismissedReason",
                                ContextualPanelDismissedReason::KeyboardOpened);
  [self.contextualSheetHandler closeContextualSheet];
}

@end