PinLayout icon indicating copy to clipboard operation
PinLayout copied to clipboard

Improve Views Concatenation Methods

Open pasp94 opened this issue 3 years ago • 3 comments

Description

This PR aims to improve to improve the behaviour of methods like below(of ... or above(of ... allowing to pin the view to the top/bottom of its superview.

Motivation and Context

Using PinLayout frequently to chain views with the following APIs below(of ..., above(of ..., or after(of ... combined with visible( ... ) method, it's easy and fast but we often find ourself facing the same problem: the view on top must always be visible. In some cases, however, it would be useful and convenient to overcome this trivial limitation by anchoring the first visible view to the superview’s edge.

The solution adopted by me and also my colleagues is to use an if else statement inside the layout method that check the visibility of the previous/next relative view:

func performLayout() {
    firstView.pin
      .top()
      .size(100)
      .hCenter()
      .marginTop(10)
    
    if firstView.isHidden {
      secondView.pin
        .top()
    } else {
      secondView.pin
        .below(of: firstView)
    }
    ...
}

With this pull request I would like to discuss a possible solution implemented as a set of methods that after checking the visibility state of his relative views, instead of causing the layout to fail, pin the view to the edge of the superview (top/bottom/left/right depending on the method used).

/// Example of a view that is attached below of the last visible `relativeViews`
/// or to `top()` if all relative views are hidden.
@discardableResult
public func topIfNotBelow(of relativeViews: [PinView], aligned: HorizontalAlign = .none) -> PinLayout {
  func context() -> String { return relativeContext("below", relativeViews, aligned) }
  return topIfNotBelow(of: relativeViews, aligned: aligned, context: context)
}

fileprivate func topIfNotBelow(of relativeViews: [PinView], aligned: HorizontalAlign, context: Context ) -> PinLayout {
  guard layoutSuperview(context) != nil else { return self }
  guard relativeViews.count > 0 else {
    setTop(0, context)
    return self
  }
  
  let anchors: [Anchor]
  switch aligned {
  case .left:   anchors = relativeViews.map({ $0.anchor.bottomLeft })
  case .center: anchors = relativeViews.map({ $0.anchor.bottomCenter })
  case .right:  anchors = relativeViews.map({ $0.anchor.bottomRight })
  case .start:  anchors = isLTR() ? relativeViews.map({ $0.anchor.bottomLeft }) : relativeViews.map({ $0.anchor.bottomRight })
  case .end:    anchors = isLTR() ? relativeViews.map({ $0.anchor.bottomRight }) : relativeViews.map({ $0.anchor.bottomLeft })
  case .none:   anchors = relativeViews.map({ $0.anchor.bottomLeft })
  }
  
  if let coordinates = computeCoordinates(forAnchors: anchors, context) {
    setTop(getBottomMostCoordinate(list: coordinates), context)
    applyHorizontalAlignment(aligned, coordinates: coordinates, context: context)
  }
  return self
}

Using this method as follow:

func performLayout() {
  redView.pin
    .top()
    .size(100)
    .hCenter()
    .marginTop(10)
  
  blueView.pin
    .topIfNotBelow(of: visible([redView]))
    .size(100)
    .hCenter()
    .marginTop(12)
  
  greenView.pin
    .topIfNotBelow(of: visible([redView, blueView]))
    .size(100)
    .hCenter()
    .marginTop(12)
  
  ...
}

I’ll leave here a short video to show animations as well.

https://user-images.githubusercontent.com/16403933/196270311-1e2a0587-b916-4f21-9967-539953ae2c00.mp4

More Thoughts

It could also be thought of a version of this proposal that improves the names of the methods added by me. Existing functions (such as below(of ...)) could be improved by adding a parameter that describes where the view should be pin in case there are no relative views visible (as default it will maintain the default behaviour):

enum ParentVerticaAnchor {
  case none
  
  case top
  
  case bottom
  
  case center
}

fileprivate func below(of relativeViews: [PinView], aligned: HorizontalAlign, ifNeeded anchor: ParentVerticaAnchor = .none, context: Context) -> PinLayout {
  guard layoutSuperview(context) != nil else { return self }
  
  switch anchor {
  case .none:
    guard relativeViews.count > 0 else {
      warnWontBeApplied("At least one view must be visible (i.e. UIView.isHidden != true) ", context)
      return self
    }
    
  case .top:
    guard relativeViews.count > 0 else {
      setTop(0, context)
      return self
    }
    
  case .bottom:
    guard relativeViews.count > 0 else {
      setBottom(0, context)
      return self
    }
    
  case .center:
    guard relativeViews.count > 0 else {
      setVerticalCenter(0, context)
      return self
    }
  }
  
  let anchors: [Anchor]
  switch aligned {
    case .left:   anchors = relativeViews.map({ $0.anchor.bottomLeft })
    case .center: anchors = relativeViews.map({ $0.anchor.bottomCenter })
    case .right:  anchors = relativeViews.map({ $0.anchor.bottomRight })
    case .start:  anchors = isLTR() ? relativeViews.map({ $0.anchor.bottomLeft }) : relativeViews.map({ $0.anchor.bottomRight })
    case .end:    anchors = isLTR() ? relativeViews.map({ $0.anchor.bottomRight }) : relativeViews.map({ $0.anchor.bottomLeft })
    case .none:   anchors = relativeViews.map({ $0.anchor.bottomLeft })
  }
  
  if let coordinates = computeCoordinates(forAnchors: anchors, context) {
    setTop(getBottomMostCoordinate(list: coordinates), context)
    applyHorizontalAlignment(aligned, coordinates: coordinates, context: context)
  }
  return self
}

pasp94 avatar Oct 17 '22 20:10 pasp94

Hi @pasp94, thanks for this proposition. I understand your requirement, but I need a day or 2 to think about it, i.e. if its the best way to support that by still keeping a clean interface.

lucdion avatar Oct 18 '22 18:10 lucdion

Hi @pasp94,

After more thinking, your proposition is a little bit too specific to the case you want to handle.

Here are my explanations:

  • Your proposition topIfNotBelow(...) works only for the specific case where you want to layout the view at the top of its superview when all relative views are hidden. Its not always the case. Suppose that in the "Introduction Example", the UISegmentedControl could be hidden, the layout of the textLabel following it wich is layouted using textLabel.pin.below(of: segmented, aligned: .left).... wouldn't work with topIfNotBelow(of: visible([segmented]), aligned: .left) because the textLabel would be wrongly layouted (see attached screenshot)

  • Your proposition also only works if you want to apply all commands following topIfNotBelow(...), in your example .size(100).hCenter().marginTop(12). But in many cases we would want to apply a different set of commands when all relative views are hidden. Note that in your example if the redView is visible the padding of the first view will be 10 pixels, but if the redView is hidden the padding will be 12 pixels.

To be accepted, it would need to be more generic. So for this reason your proposition cannot be integrated into PinLayout. Sorry, but if you come up with a more generic solution, I would be really interrested, I agree that something like your proposition is missing.

One possible solution for you case would be to use a variable that keeps the top position.

func performLayout() {
  var top = 0.0

  redView.pin.top(top).size(100).hCenter().marginTop(10)
  top = redView.isHidden ? top : redView.frame.maxY

  blueView.pin.top(top).size(100).hCenter().marginTop(12)
  top = blueView.isHidden ? top : blueView.frame.maxY
  
  greenView.pin.top(top).size(100).hCenter().marginTop(12)
  top = greenView.isHidden ? top : greenView.frame.maxY
  ...
}

Or you could even do something like this:

func performLayout() {
  var top = 0.0
  var marginTop = 10

  [redView, blueView, greenView].forEach {
  	$0.pin.top(top).size(100).hCenter().marginTop(marginTop)
  	top = $0.isHidden ? top : $0.frame.maxY
  	marginTop = 12
  }
}

Thanks again and sorry

Simulator Screen Shot - iPhone X - Dev2 - 2022-10-18 at 15 08 41

lucdion avatar Oct 18 '22 19:10 lucdion

Hi @lucdion, I'm so sorry for the delay in my answer. Thank you so much for your answer and your analysis. I understood the issues and limits of my proposal and will try to make it more generic and improve the approach. 😊

pasp94 avatar Oct 19 '22 21:10 pasp94