chromium/ios/chrome/browser/shared/ui/bottom_sheet/table_view_bottom_sheet_view_controller.mm

// Copyright 2023 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/shared/ui/bottom_sheet/table_view_bottom_sheet_view_controller.h"

#import <Foundation/Foundation.h>
#import <optional>
#import <ostream>

#import "base/check.h"
#import "base/notreached.h"
#import "ios/chrome/browser/shared/ui/bottom_sheet/table_view_bottom_sheet_view_controller+subclassing.h"
#import "ios/chrome/common/ui/table_view/table_view_cells_constants.h"

namespace {

// Estimated row height for each cell in the table view.
CGFloat const kTableViewEstimatedRowHeight = 75;

// Radius size of the table view.
CGFloat const kTableViewCornerRadius = 10;

// Custom detent identifier for when the bottom sheet is minimized.
NSString* const kCustomMinimizedDetentIdentifier = @"customMinimizedDetent";

// Default custom detent identifier.
NSString* const kCustomDetentIdentifier = @"customDetent";

}  // namespace

@interface TableViewBottomSheetViewController () <
    UISheetPresentationControllerDelegate> {
  // Table view for the list of suggestions.
  UITableView* _tableView;

  // If YES: the table view is currently showing a number of rows equal to
  // `initialNumberOfVisibleCells`. If NO: the table view is currently showing
  // all rows.
  BOOL _tableViewIsMinimized;

  // Height constraint for the bottom sheet when showing a number of rows equal
  // to `initialNumberOfVisibleCells`.
  NSLayoutConstraint* _minimizedHeightConstraint;

  // Height constraint for the bottom sheet when showing all suggestions.
  NSLayoutConstraint* _heightConstraint;

  // YES if the expanded bottom sheet size takes the whole screen.
  BOOL _expandSizeTooLarge;

  // Keep track of the minimized state height.
  std::optional<CGFloat> _minimizedStateHeight;
}

@end

@implementation TableViewBottomSheetViewController

- (void)reloadTableViewData {
  [_tableView reloadData];
  [self updateHeight];
}

- (NSInteger)selectedRow {
  return _tableView.indexPathForSelectedRow.row;
}

- (CGFloat)tableViewWidth {
  return _tableView.frame.size.width;
}

- (UIEdgeInsets)separatorInsetForTableViewWidth:(CGFloat)tableViewWidth
                                    atIndexPath:(NSIndexPath*)indexPath {
  // Make separator invisible on last cell
  CGFloat separatorLeftMargin =
      [self isLastRow:indexPath] ? tableViewWidth : kTableViewHorizontalSpacing;
  return UIEdgeInsetsMake(0.f, separatorLeftMargin, 0.f, 0.f);
}

- (UITableViewCellAccessoryType)accessoryType:(NSIndexPath*)indexPath {
  return ([self selectedRow] == indexPath.row)
             ? UITableViewCellAccessoryCheckmark
             : UITableViewCellAccessoryNone;
}

- (void)adjustTransactionsPrimaryActionButtonHorizontalConstraints {
  CGFloat buttonHorizontalMargin =
      ([[UIDevice currentDevice] userInterfaceIdiom] == UIUserInterfaceIdiomPad
           ? 64.0
           : 24.0);

  [self.primaryActionButton.leadingAnchor
      constraintEqualToAnchor:(self.view.leadingAnchor)
                     constant:buttonHorizontalMargin]
      .active = YES;
  [self.primaryActionButton.trailingAnchor
      constraintEqualToAnchor:(self.view.trailingAnchor)
                     constant:-buttonHorizontalMargin]
      .active = YES;
}

#pragma mark - Subclassing

- (UITableView*)createTableView {
  _tableView = [[UITableView alloc] initWithFrame:CGRectZero
                                            style:UITableViewStylePlain];

  _tableView.layer.cornerRadius = kTableViewCornerRadius;
  _tableView.estimatedRowHeight = kTableViewEstimatedRowHeight;
  _tableView.scrollEnabled = NO;
  _tableView.showsVerticalScrollIndicator = NO;
  _tableView.delegate = self;
  _tableView.userInteractionEnabled = YES;

  _tableView.translatesAutoresizingMaskIntoConstraints = NO;

  _minimizedHeightConstraint = [_tableView.heightAnchor
      constraintEqualToConstant:kTableViewEstimatedRowHeight *
                                [self initialNumberOfVisibleCells]];
  _minimizedHeightConstraint.priority = UILayoutPriorityDefaultLow;
  _heightConstraint = [_tableView.heightAnchor
      constraintEqualToConstant:kTableViewEstimatedRowHeight * [self rowCount]];

  _minimizedHeightConstraint.active = _tableViewIsMinimized;
  _heightConstraint.active = !_tableViewIsMinimized;

  [self selectFirstRow];

  return _tableView;
}

- (NSUInteger)rowCount {
  NOTREACHED() << "Subclasses of TableViewBottomSheetViewController "
                  "must implement this method.";
}

- (CGFloat)computeTableViewCellHeightAtIndex:(NSUInteger)index {
  NOTREACHED() << "Subclasses of TableViewBottomSheetViewController "
                  "must implement this method.";
}

#pragma mark - UIViewController

- (void)viewDidLoad {
  self.view.accessibilityViewIsModal = YES;

  // If the table has too many rows for the initial state, we open bottom sheet
  // minimized.
  _tableViewIsMinimized = [self rowCount] > [self initialNumberOfVisibleCells];

  self.underTitleView = [self createTableView];

  // Set the properties read by the super when constructing the
  // views in `-[ConfirmationAlertViewController viewDidLoad]`.
  self.imageHasFixedSize = YES;
  self.showsVerticalScrollIndicator = NO;
  self.showDismissBarButton = NO;
  self.topAlignedLayout = YES;
  self.customScrollViewBottomInsets = 0;

  [super viewDidLoad];

  [self displayGradientView:NO];

  // Assign table view's width anchor now that it is in the same hierarchy as
  // the top view.
  [_tableView.widthAnchor
      constraintEqualToAnchor:self.primaryActionButton.widthAnchor]
      .active = YES;

  [self setUpBottomSheetDetents];

  // Set selection to the first one.
  [self selectFirstRow];
}

- (void)viewIsAppearing:(BOOL)animated {
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 170000
  [super viewIsAppearing:animated];
#endif

  [self updateHeight];
}

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

  if (self.traitCollection.preferredContentSizeCategory !=
      previousTraitCollection.preferredContentSizeCategory) {
    _minimizedStateHeight = std::nullopt;
    [self updateHeight];
  }
}

#pragma mark - UITableViewDelegate

- (void)tableView:(UITableView*)tableView
    didSelectRowAtIndexPath:(NSIndexPath*)indexPath {
  [tableView cellForRowAtIndexPath:indexPath].accessoryType =
      UITableViewCellAccessoryCheckmark;
}

- (void)tableView:(UITableView*)tableView
    didDeselectRowAtIndexPath:(NSIndexPath*)indexPath {
  [tableView cellForRowAtIndexPath:indexPath].accessoryType =
      UITableViewCellAccessoryNone;
}

// It is called when the table view is about to draw a cell for a particular
// row.
- (void)tableView:(UITableView*)tableView
      willDisplayCell:(UITableViewCell*)cell
    forRowAtIndexPath:(NSIndexPath*)indexPath {
  // If only one suggestion exists, the item should not be selectable.
  cell.userInteractionEnabled = [self rowCount] > 1;
}

#pragma mark - UIScrollViewDelegate

- (void)scrollViewDidScroll:(UIScrollView*)scrollView {
  [self displayGradientView:![self isScrolledToBottom]];
}

#pragma mark - UISheetPresentationControllerDelegate

- (void)sheetPresentationControllerDidChangeSelectedDetentIdentifier:
    (UISheetPresentationController*)sheetPresentationController
    API_AVAILABLE(ios(16)) {
  // Show the gradient view to let the user know that the view can be scrolled
  // when the bottom sheet is in minimized state or if the expanded state takes
  // more space than the screen.
  NSString* selectedDetentIdentifier =
      sheetPresentationController.selectedDetentIdentifier;
  [self displayGradientView:selectedDetentIdentifier ==
                                kCustomMinimizedDetentIdentifier ||
                            (selectedDetentIdentifier ==
                                 kCustomDetentIdentifier &&
                             _expandSizeTooLarge)];
}

#pragma mark - Private

// Maximum initial number of visible cells.
- (CGFloat)initialNumberOfVisibleCells {
  return 2.5;
}

// Select the first row in the table view.
- (void)selectFirstRow {
  [_tableView selectRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]
                          animated:NO
                    scrollPosition:UITableViewScrollPositionNone];
}

// Mocks cells to compute the table view height for the given number of rows.
- (CGFloat)computeTableViewHeight:(NSUInteger)rowCount {
  CGFloat height = 0;
  for (NSUInteger i = 0; i < rowCount; i++) {
    CGFloat cellHeight = [self computeTableViewCellHeightAtIndex:i];
    height += cellHeight;
  }
  return height;
}

// Updates the bottom sheet's height.
- (void)updateHeight {
  BOOL useMinimizedState = _tableViewIsMinimized;

  NSUInteger rowCount = [self rowCount];
  if (rowCount) {
    [self.view layoutIfNeeded];
    CGFloat fullHeight = [self computeTableViewHeight:rowCount];
    if (fullHeight > 0) {
      // Update height constraints for the table view.
      _heightConstraint.constant = fullHeight;

      if (rowCount > [self initialNumberOfVisibleCells]) {
        _minimizedHeightConstraint.constant =
            [self computeTableViewHeightForMinimizedState:rowCount];
      } else {
        _minimizedHeightConstraint.constant = fullHeight;
      }

      // Do not use minized state if it is larger than the superview height.
      useMinimizedState &=
          [self initialHeight] < self.parentViewControllerHeight;
    }
  }

  // Update the custom detent with the correct initial height for the bottom
  // sheet. (Initial height is not calculated properly in -viewDidLoad, but we
  // need to setup the bottom sheet in that method so there is not a delay when
  // showing the table view and the action buttons).
  UISheetPresentationController* presentationController =
      self.sheetPresentationController;
  presentationController.delegate = self;
  // Setup the minimized height (if the table has more than
  // `initialNumberOfVisibleCells` rows).
  NSMutableArray* currentDetents = [[NSMutableArray alloc] init];
  if (@available(iOS 16, *)) {
    if (useMinimizedState) {
      // Show gradient view when the user is in minimized state to show that the
      // view can be scrolled.
      [self displayGradientView:YES];

      CGFloat bottomSheetHeight = [self initialHeight];
      auto detentBlock = ^CGFloat(
          id<UISheetPresentationControllerDetentResolutionContext> context) {
        return bottomSheetHeight;
      };
      UISheetPresentationControllerDetent* customDetent =
          [UISheetPresentationControllerDetent
              customDetentWithIdentifier:kCustomMinimizedDetentIdentifier
                                resolver:detentBlock];
      [currentDetents addObject:customDetent];
    }
  }

  // Done calculating the height for the bottom sheet for
  // `initialNumberOfVisibleCells` rows, disable minimized height constraint.
  _minimizedHeightConstraint.active = NO;
  _heightConstraint.active = YES;

  // Calculate the full height of the bottom sheet with the minimized height
  // constraint disabled.
  if (@available(iOS 16, *)) {
    __weak __typeof(self) weakSelf = self;
    auto fullHeightBlock = ^CGFloat(
        id<UISheetPresentationControllerDetentResolutionContext> context) {
      return [weakSelf computeHeight:context.maximumDetentValue];
    };
    UISheetPresentationControllerDetent* customDetentExpand =
        [UISheetPresentationControllerDetent
            customDetentWithIdentifier:kCustomDetentIdentifier
                              resolver:fullHeightBlock];
    [currentDetents addObject:customDetentExpand];
    presentationController.detents = currentDetents;
    presentationController.selectedDetentIdentifier =
        useMinimizedState ? kCustomMinimizedDetentIdentifier
                          : kCustomDetentIdentifier;
  }
}

// Returns whether the provided index path points to the last row of the table
// view.
- (BOOL)isLastRow:(NSIndexPath*)indexPath {
  return NSUInteger(indexPath.row) == ([self rowCount] - 1);
}

// Mocks the cells to calculate the real table view height in minized state.
- (CGFloat)computeTableViewHeightForMinimizedState:(NSUInteger)rowCount {
  CHECK(rowCount > [self initialNumberOfVisibleCells]);
  CGFloat height = 0;
  NSInteger count =
      static_cast<NSInteger>(floor([self initialNumberOfVisibleCells]));
  for (NSInteger i = 0; i <= count; i++) {
    CGFloat cellHeight = [self computeTableViewCellHeightAtIndex:i];
    if (i == count) {
      CGFloat diff = abs([self initialNumberOfVisibleCells] - count);
      height += cellHeight * diff;
    } else {
      height += cellHeight;
    }
  }
  return height;
}

// Returns the bottom sheet's height, limited to the maximum possible height.
- (CGFloat)computeHeight:(CGFloat)maximumDetentValue {
  CGFloat preferredHeight = [self preferredHeightForContent];
  _expandSizeTooLarge = (preferredHeight > maximumDetentValue);
  return _expandSizeTooLarge ? maximumDetentValue : preferredHeight;
}

- (CGFloat)initialHeight {
  if (!_minimizedStateHeight.has_value()) {
    _minimizedStateHeight = [self preferredHeightForContent];
  }
  return _minimizedStateHeight.value();
}
@end