Teddy K
Teddy K

Reputation: 870

How can I animate this tableview header Y position change

I want to slide my table view header into view if the use scrolls up my feed and the header is off screen. If the user then scrolls down I want to hide it again.

I believe I have this working using the following:

final class AccountSettingsController: UITableViewController {

  let items: [Int] = Array(0...500)

  private lazy var menuView: UIView = {
    let view = UIView(frame: .zero)
    view.translatesAutoresizingMaskIntoConstraints = false
    view.backgroundColor = .systemPink
    view.heightAnchor.constraint(equalToConstant: 120).isActive = true
    view.widthAnchor.constraint(equalToConstant: 100).isActive = true
    return view
  }()

  override func viewDidLoad() {
    super.viewDidLoad()

    edgesForExtendedLayout = []
    extendedLayoutIncludesOpaqueBars = false

    tableView.tableHeaderViewWithAutolayout = menuView

    tableView.tableFooterView = .init()
    tableView.refreshControl = .init()
  }


  override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return items.count
  }

  override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = UITableViewCell()
    cell.textLabel?.text = "This is setting #\(indexPath.row)"

    return cell
  }

  override func scrollViewDidScroll(_ scrollView: UIScrollView) {
    let contentOffset = scrollView.contentOffset.y
    let transY = scrollView.panGestureRecognizer.translation(in: scrollView).y

    if scrollView.contentOffset.y > 120 {

      if transY > 0 {
        menuView.transform = .init(translationX: 0, y: contentOffset)

      } else if transY < 0 {
        menuView.transform = .identity
      }

      return
    }


    if scrollView.contentOffset.y <= 120 {

      if transY < 0 {
         menuView.transform = .identity

      } else if transY > 0 {
        menuView.transform = .init(translationX: 0, y: contentOffset)
      }

    }

  }
}

I would like to animate the slide in / slide out of the header, so it slides down and up, rather than just appears.

I tried to add UIView.animate blocks such as

  if scrollView.contentOffset.y <= 120 {

      if transY < 0 {
         menuView.transform = .identity

      } else if transY > 0 {
        UIView.animate(withDuration: 0.25, animations: {
          self.menuView.transform = .init(translationX: 0, y: contentOffset)
        })
      }

    }

but this produces a random jumping effect on the header and does not achieve what I would like at all.

I've attached a gif that shows the current effect, as you can see it just appears and disappears. I'd like this to animate down and up for for show / hide.

I also have an extension to set the correct size for my table view header, which you can find here -


extension UITableView {
  var tableHeaderViewWithAutolayout: UIView? {
    set (view) {
      tableHeaderView = view
      if let view = view {
        lowerPriorities(view)
        view.frame.size = view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
        tableHeaderView = view
      }
    }
    get {
      return tableHeaderView
    }
  }

  fileprivate func lowerPriorities(_ view: UIView) {
    for cons in view.constraints {
      if cons.priority.rawValue == 1000 {
        cons.priority = UILayoutPriority(rawValue: 999)
      }
      for v in view.subviews {
        lowerPriorities(v)
      }
    }
  }
}

display attempt

Upvotes: 2

Views: 1388

Answers (2)

Claudio
Claudio

Reputation: 5213

I believe the best approach is by setting the menuView as a subview of the tableViewController, for example on the viewDidLoad method and also adding an inset on the tableView like this:

let menuHeight: CGFloat = 120
var scrollStartingYPoint: CGFloat = 0

private lazy var menuView: UIView = {
  let view = UIView(frame: .zero)
  view.translatesAutoresizingMaskIntoConstraints = false
  view.backgroundColor = .systemPink
  view.heightAnchor.constraint(equalToConstant: menuHeight).isActive = true
  view.widthAnchor.constraint(equalToConstant: 100).isActive = true
  return view
}()

override func viewDidLoad() {
    super.viewDidLoad()
    // Do any additional setup after loading the view.
    view.addSubview(menuView)

    NSLayoutConstraint.activate([
        menuView.leftAnchor.constraint(equalTo: view.leftAnchor),
        menuView.topAnchor.constraint(equalTo: view.topAnchor)
    ])

    edgesForExtendedLayout = []
    extendedLayoutIncludesOpaqueBars = false

    tableView.contentInset.top = menuHeight
}

Then you can add the scrolling logic to show/hide the menu, I've done it a little different than you using the scrollViewWillBeginDragging method to store the initial scroll offset, to then determine the scroll direction and offset:

func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
    scrollStartingYPoint = scrollView.contentOffset.y
}

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    let tableYScrollOffset = scrollView.contentOffset.y
    let offset = abs(tableYScrollOffset - scrollStartingYPoint)

    if tableYScrollOffset < scrollStartingYPoint { // scrolling down
        if menuView.frame.origin.y >= 0 {
            return
        }

        var translationY = -menuHeight + offset

        if translationY > 0 {
            translationY = 0
        }

        menuView.transform = CGAffineTransform(translationX: 0, y: translationY)
    } else { // scrolling up
        if menuView.frame.origin.y <= -menuHeight {
            return
        }

        var translationY = -offset

        if translationY < -menuHeight {
            translationY = -menuHeight
        }

        menuView.transform = CGAffineTransform(translationX: 0, y: translationY)
    }
}

If you want to animate the presentation of the menu, then you can change the previous code and use your animation code like this:

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    let tableYScrollOffset = scrollView.contentOffset.y
    let offset = abs(tableYScrollOffset - scrollStartingYPoint)

    if tableYScrollOffset < scrollStartingYPoint { // scrolling down
        UIView.animate(withDuration: 0.25, animations: {
          self.menuView.transform = .identity
        })
    } else { // scrolling up
        if menuView.frame.origin.y <= -menuHeight {
            return
        }

        var translationY = -offset

        if translationY < -menuHeight {
            translationY = -menuHeight
        }

        menuView.transform = CGAffineTransform(translationX: 0, y: translationY)
    }
}

Upvotes: 1

nodediggity
nodediggity

Reputation: 2478

I agree with Claudio, using the tableHeaderView is probably complicating things as it is not really intended to be manipulated in this way.

Try the below and see if this gets you what you are looking for.

Instead of manipulating the offset of the header, manipulate the height. You can still give the impression of a scroll.

You could also introduce scrollView.panGestureRecognizer.velocity(in: scrollView) if you wanted to apply a threshold so the menu appears only after a particular scroll speed.

class MyTableViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {

  private let data: [Int] = Array(0...99)

  private let menuView: UIView = {
    let view = UIView(frame: .zero)
    view.backgroundColor = .purple
    view.translatesAutoresizingMaskIntoConstraints = false
    return view
  }()

  private lazy var tableView: UITableView = {
    let view = UITableView(frame: .zero)
    view.translatesAutoresizingMaskIntoConstraints = false
    view.dataSource = self
    view.delegate = self
//    view.refreshControl = .init()
    view.contentInsetAdjustmentBehavior = .never
    view.tableFooterView = .init()
    return view
  }()

  var menuViewHeightAnchor: NSLayoutConstraint!
  var tableViewTopAnchor: NSLayoutConstraint!

  override func viewDidLoad() {
    super.viewDidLoad()

    menuViewHeightAnchor = menuView.heightAnchor.constraint(equalToConstant: 120)
    tableViewTopAnchor = tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 120)

    edgesForExtendedLayout = []
    [tableView, menuView].forEach(view.addSubview)

    NSLayoutConstraint.activate([
      menuViewHeightAnchor,
      menuView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
      menuView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
      menuView.trailingAnchor.constraint(equalTo: view.trailingAnchor),

      tableViewTopAnchor,
      tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
      tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
      tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
    ])
  }

  func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return data.count
  }

  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = UITableViewCell(frame: .zero)
    cell.textLabel?.text = "Cell #\(indexPath.row)"
    return cell
  }

  private var previousOffsetY: CGFloat = 0
  private var velocityThreshold: CGFloat = 500


  func scrollViewDidScroll(_ scrollView: UIScrollView) {
    let offsetY = min(120, max(0, scrollView.contentOffset.y))
    let translation = scrollView.panGestureRecognizer.translation(in: scrollView)
    let velocity = scrollView.panGestureRecognizer.velocity(in: scrollView)

    let offsetYDiff = previousOffsetY - offsetY
    previousOffsetY = offsetY

    let adjustedOffset = menuViewHeightAnchor.constant + offsetYDiff

    tableViewTopAnchor.constant = 120 - max(0, offsetY)
    menuViewHeightAnchor.constant = min(120, adjustedOffset)


    guard offsetY == 120 else { return }

    if translation.y > 0  { // DRAGGED DOWN

      UIView.animate(withDuration: 0.33, animations: {
        self.menuViewHeightAnchor.constant = 120
        self.view.layoutIfNeeded()
      })

    } else if translation.y < 0 { // DRAGGED UP

      UIView.animate(withDuration: 0.33, animations: {
        self.menuViewHeightAnchor.constant = 0
        self.view.layoutIfNeeded()
      })
    }
  }

}

Upvotes: 1

Related Questions