我正在实现一个iPhone应用程序,我正在尝试构建该功能,它是Card刷卡(与Tinder相同)和滚动效果。请查看以下详细信息。
当用户打开应用程序时,它会显示其他用户个人资料。所以登录用户可以向左(向右)或向右(如)刷卡,该卡将从列表中删除,并且将出现下一张用户卡。现在,如果用户不想刷卡,那么他可以向上或向下滚动以查看其他用户个人资料。
那么,是否可以实现刷卡和刷卡的组合;滚动功能。
答案 0 :(得分:1)
我知道这个问题现在有点老了,但是是,这正是我最近几个月一直在构建的。
我基本上将UIPanGestureRecognizer
添加到了UICollectionView
,并具有自定义的布局(用于卡片分页等)。
这是它的样子:
主要组件逻辑:
import Foundation
/**
The VerticalCardSwiper is a subclass of `UIView` that has a `VerticalCardSwiperView` embedded.
To use this, you need to implement the `VerticalCardSwiperDatasource`.
If you want to handle actions like cards being swiped away, implement the `VerticalCardSwiperDelegate`.
*/
public class VerticalCardSwiper: UIView {
/// The collectionView where all the magic happens.
public var verticalCardSwiperView: VerticalCardSwiperView!
/// Indicates if side swiping on cards is enabled. Default value is `true`.
@IBInspectable public var isSideSwipingEnabled: Bool = true
/// The inset (spacing) at the top for the cards. Default is 40.
@IBInspectable public var topInset: CGFloat = 40 {
didSet {
setCardSwiperInsets()
}
}
/// The inset (spacing) at each side of the cards. Default is 20.
@IBInspectable public var sideInset: CGFloat = 20 {
didSet {
setCardSwiperInsets()
}
}
/// Sets how much of the next card should be visible. Default is 50.
@IBInspectable public var visibleNextCardHeight: CGFloat = 50 {
didSet {
setCardSwiperInsets()
}
}
/// Vertical spacing between CardCells. Default is 40.
@IBInspectable public var cardSpacing: CGFloat = 40 {
willSet {
flowLayout.minimumLineSpacing = newValue
}
}
/// The transform animation that is shown on the top card when scrolling through the cards. Default is 0.05.
@IBInspectable public var firstItemTransform: CGFloat = 0.05 {
willSet {
flowLayout.firstItemTransform = newValue
}
}
public weak var delegate: VerticalCardSwiperDelegate?
public weak var datasource: VerticalCardSwiperDatasource? {
didSet{
numberOfCards = datasource?.numberOfCards(verticalCardSwiperView: self.verticalCardSwiperView) ?? 0
}
}
/// The amount of cards in the collectionView.
fileprivate var numberOfCards: Int = 0
/// We use this horizontalPangestureRecognizer for the vertical panning.
fileprivate var horizontalPangestureRecognizer: UIPanGestureRecognizer!
/// Stores a `CGRect` with the area that is swipeable to the user.
fileprivate var swipeAbleArea: CGRect!
/// The `CardCell` that the user can (and is) moving.
fileprivate var swipedCard: CardCell! {
didSet {
setupCardSwipeDelegate()
}
}
/// The flowlayout used in the collectionView.
fileprivate lazy var flowLayout: VerticalCardSwiperFlowLayout = {
let flowLayout = VerticalCardSwiperFlowLayout()
flowLayout.firstItemTransform = firstItemTransform
flowLayout.minimumLineSpacing = cardSpacing
flowLayout.isPagingEnabled = true
return flowLayout
}()
public override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
public required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
fileprivate func commonInit() {
setupVerticalCardSwiperView()
setupConstraints()
setCardSwiperInsets()
setupGestureRecognizer()
}
}
extension VerticalCardSwiper: CardDelegate {
internal func willSwipeAway(cell: CardCell, swipeDirection: SwipeDirection) {
verticalCardSwiperView.isUserInteractionEnabled = false
if let index = verticalCardSwiperView.indexPath(for: cell)?.row {
self.delegate?.willSwipeCardAway?(card: cell, index: index, swipeDirection: swipeDirection)
}
}
internal func didSwipeAway(cell: CardCell, swipeDirection direction: SwipeDirection) {
if let indexPathToRemove = verticalCardSwiperView.indexPath(for: cell){
self.numberOfCards -= 1
swipedCard = nil
self.verticalCardSwiperView.performBatchUpdates({
self.verticalCardSwiperView.deleteItems(at: [indexPathToRemove])
}) { [weak self] (finished) in
if finished {
self?.verticalCardSwiperView.collectionViewLayout.invalidateLayout()
self?.verticalCardSwiperView.isUserInteractionEnabled = true
self?.delegate?.didSwipeCardAway?(card: cell, index: indexPathToRemove.row ,swipeDirection: direction)
}
}
}
}
internal func didDragCard(cell: CardCell, swipeDirection: SwipeDirection) {
if let index = verticalCardSwiperView.indexPath(for: cell)?.row {
self.delegate?.didDragCard?(card: cell, index: index, swipeDirection: swipeDirection)
}
}
fileprivate func setupCardSwipeDelegate() {
swipedCard?.delegate = self
}
}
extension VerticalCardSwiper: UIGestureRecognizerDelegate {
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if let panGestureRec = gestureRecognizer as? UIPanGestureRecognizer {
// When a horizontal pan is detected, we make sure to disable the collectionView.panGestureRecognizer so that it doesn't interfere with the sideswipe.
if panGestureRec == horizontalPangestureRecognizer, panGestureRec.direction!.isX {
return false
}
}
return true
}
/// We set up the `horizontalPangestureRecognizer` and attach it to the `collectionView`.
fileprivate func setupGestureRecognizer(){
horizontalPangestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
horizontalPangestureRecognizer.maximumNumberOfTouches = 1
horizontalPangestureRecognizer.delegate = self
verticalCardSwiperView.addGestureRecognizer(horizontalPangestureRecognizer)
verticalCardSwiperView.panGestureRecognizer.maximumNumberOfTouches = 1
}
/**
This function is called when a pan is detected inside the `collectionView`.
We also take care of detecting if the pan gesture is inside the `swipeAbleArea` and we animate the cell if necessary.
- parameter sender: The `UIPanGestureRecognizer` that detects the pan gesture. In this case `horizontalPangestureRecognizer`.
*/
@objc fileprivate func handlePan(sender: UIPanGestureRecognizer){
guard isSideSwipingEnabled else {
return
}
/// The taplocation relative to the superview.
let location = sender.location(in: self)
/// The taplocation relative to the collectionView.
let locationInCollectionView = sender.location(in: verticalCardSwiperView)
/// The translation of the finger performing the PanGesture.
let translation = sender.translation(in: self)
if swipeAbleArea.contains(location) && !verticalCardSwiperView.isScrolling {
if let swipedCardIndex = verticalCardSwiperView.indexPathForItem(at: locationInCollectionView) {
/// The card that is swipeable inside the SwipeAbleArea.
swipedCard = verticalCardSwiperView.cellForItem(at: swipedCardIndex) as? CardCell
}
}
if swipedCard != nil && !verticalCardSwiperView.isScrolling {
/// The angle we pass for the swipe animation.
let maximumRotation: CGFloat = 1.0
let rotationStrength = min(translation.x/swipedCard.frame.width, maximumRotation)
let angle = (CGFloat.pi/10.0) * rotationStrength
switch (sender.state) {
case .began:
break
case .changed:
swipedCard.animateCard(angle: angle, horizontalTranslation: translation.x)
break
case .ended:
swipedCard.endedPanAnimation(angle: angle)
swipedCard = nil
break
default:
swipedCard.resetToCenterPosition()
swipedCard = nil
}
}
}
}
extension VerticalCardSwiper: UICollectionViewDelegate, UICollectionViewDataSource {
/**
Reloads all of the data for the VerticalCardSwiperView.
Call this method sparingly when you need to reload all of the items in the VerticalCardSwiper. This causes the VerticalCardSwiperView to discard any currently visible items (including placeholders) and recreate items based on the current state of the data source object. For efficiency, the VerticalCardSwiperView only displays those cells and supplementary views that are visible. If the data shrinks as a result of the reload, the VerticalCardSwiperView adjusts its scrolling offsets accordingly.
*/
public func reloadData(){
verticalCardSwiperView.reloadData()
}
/**
Register a class for use in creating new CardCells.
Prior to calling the dequeueReusableCell(withReuseIdentifier:for:) method of the collection view,
you must use this method or the register(_:forCellWithReuseIdentifier:) method
to tell the collection view how to create a new cell of the given type.
If a cell of the specified type is not currently in a reuse queue,
the VerticalCardSwiper uses the provided information to create a new cell object automatically.
If you previously registered a class or nib file with the same reuse identifier,
the class you specify in the cellClass parameter replaces the old entry.
You may specify nil for cellClass if you want to unregister the class from the specified reuse identifier.
- parameter cellClass: The class of a cell that you want to use in the VerticalCardSwiper
identifier
- parameter identifier: The reuse identifier to associate with the specified class. This parameter must not be nil and must not be an empty string.
*/
public func register(_ cellClass: AnyClass?, forCellWithReuseIdentifier identifier: String) {
verticalCardSwiperView.register(cellClass, forCellWithReuseIdentifier: identifier)
}
/**
Register a nib file for use in creating new collection view cells.
Prior to calling the dequeueReusableCell(withReuseIdentifier:for:) method of the collection view,
you must use this method or the register(_:forCellWithReuseIdentifier:) method
to tell the collection view how to create a new cell of the given type.
If a cell of the specified type is not currently in a reuse queue,
the collection view uses the provided information to create a new cell object automatically.
If you previously registered a class or nib file with the same reuse identifier,
the object you specify in the nib parameter replaces the old entry.
You may specify nil for nib if you want to unregister the nib file from the specified reuse identifier.
- parameter nib: The nib object containing the cell object. The nib file must contain only one top-level object and that object must be of the type UICollectionViewCell.
identifier
- parameter identifier: The reuse identifier to associate with the specified nib file. This parameter must not be nil and must not be an empty string.
*/
public func register(nib: UINib?, forCellWithReuseIdentifier identifier: String) {
verticalCardSwiperView.register(nib, forCellWithReuseIdentifier: identifier)
}
public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return self.numberOfCards
}
public func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
return (datasource?.cardForItemAt(verticalCardSwiperView: verticalCardSwiperView, cardForItemAt: indexPath.row))!
}
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
delegate?.didScroll?(verticalCardSwiperView: verticalCardSwiperView)
}
fileprivate func setupVerticalCardSwiperView(){
verticalCardSwiperView = VerticalCardSwiperView(frame: self.frame, collectionViewLayout: flowLayout)
verticalCardSwiperView.decelerationRate = UIScrollViewDecelerationRateFast
verticalCardSwiperView.backgroundColor = UIColor.clear
verticalCardSwiperView.showsVerticalScrollIndicator = false
verticalCardSwiperView.delegate = self
verticalCardSwiperView.dataSource = self
self.numberOfCards = datasource?.numberOfCards(verticalCardSwiperView: verticalCardSwiperView) ?? 0
self.addSubview(verticalCardSwiperView)
}
fileprivate func setupConstraints(){
verticalCardSwiperView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
verticalCardSwiperView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
verticalCardSwiperView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
verticalCardSwiperView.topAnchor.constraint(equalTo: self.topAnchor),
verticalCardSwiperView.bottomAnchor.constraint(equalTo: self.bottomAnchor)
])
}
fileprivate func setCardSwiperInsets(){
verticalCardSwiperView.contentInset = UIEdgeInsets(top: topInset, left: sideInset, bottom: topInset + flowLayout.minimumLineSpacing + visibleNextCardHeight, right: sideInset)
}
}
extension VerticalCardSwiper: UICollectionViewDelegateFlowLayout {
public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let itemSize = calculateItemSize(for: indexPath.row)
// set cellHeight in the custom flowlayout, we use this for paging calculations.
flowLayout.cellHeight = itemSize.height
if swipeAbleArea == nil {
// Calculate and set the swipeAbleArea. We use this to determine wheter the cell can be swiped to the sides or not.
let swipeAbleAreaOriginY = collectionView.frame.origin.y + collectionView.contentInset.top
swipeAbleArea = CGRect(x: 0, y: swipeAbleAreaOriginY, width: self.frame.width, height: itemSize.height)
}
return itemSize
}
fileprivate func calculateItemSize(for index: Int) -> CGSize {
let cellWidth: CGFloat!
let cellHeight: CGFloat!
let xInsets = sideInset * 2
let yInsets = cardSpacing + visibleNextCardHeight + topInset
// get size from delegate if the sizeForItem function is called.
if let customSize = delegate?.sizeForItem?(verticalCardSwiperView: verticalCardSwiperView, index: index) {
// set custom sizes and make sure sizes are not negative, if they are, don't subtract the insets.
cellWidth = customSize.width - (customSize.width - xInsets > 0 ? xInsets : 0)
cellHeight = customSize.height - (customSize.height - yInsets > 0 ? yInsets : 0)
} else {
cellWidth = verticalCardSwiperView.frame.size.width - xInsets
cellHeight = verticalCardSwiperView.frame.size.height - yInsets
}
return CGSize(width: cellWidth, height: cellHeight)
}
}
自定义FlowLayout:
import UIKit
/// Custom `UICollectionViewFlowLayout` that provides the flowlayout information like paging and `CardCell` movements.
internal class VerticalCardSwiperFlowLayout: UICollectionViewFlowLayout {
/// This property sets the amount of scaling for the first item.
internal var firstItemTransform: CGFloat?
/// This property enables paging per card. The default value is true.
internal var isPagingEnabled: Bool = true
/// Stores the height of a CardCell.
internal var cellHeight: CGFloat!
internal override func prepare() {
super.prepare()
assert(collectionView!.numberOfSections == 1, "Number of sections should always be 1.")
assert(collectionView!.isPagingEnabled == false, "Paging on the collectionview itself should never be enabled. To enable cell paging, use the isPagingEnabled property of the VerticalCardSwiperFlowLayout instead.")
}
internal override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let items = NSArray (array: super.layoutAttributesForElements(in: rect)!, copyItems: true)
items.enumerateObjects(using: { (object, index, stop) -> Void in
let attributes = object as! UICollectionViewLayoutAttributes
self.updateCellAttributes(attributes)
})
return items as? [UICollectionViewLayoutAttributes]
}
// We invalidate the layout when a "bounds change" happens, for example when we scale the top cell. This forces a layout update on the flowlayout.
internal override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
// Cell paging
internal override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
// If the property `isPagingEnabled` is set to false, we don't enable paging and thus return the current contentoffset.
guard isPagingEnabled else {
let latestOffset = super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity)
return latestOffset
}
// Page height used for estimating and calculating paging.
let pageHeight = cellHeight + self.minimumLineSpacing
// Make an estimation of the current page position.
let approximatePage = self.collectionView!.contentOffset.y/pageHeight
// Determine the current page based on velocity.
let currentPage = (velocity.y < 0.0) ? floor(approximatePage) : ceil(approximatePage)
// Create custom flickVelocity.
let flickVelocity = velocity.y * 0.3
// Check how many pages the user flicked, if <= 1 then flickedPages should return 0.
let flickedPages = (abs(round(flickVelocity)) <= 1) ? 0 : round(flickVelocity)
// Calculate newVerticalOffset.
let newVerticalOffset = ((currentPage + flickedPages) * pageHeight) - self.collectionView!.contentInset.top
return CGPoint(x: proposedContentOffset.x, y: newVerticalOffset)
}
internal override func finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
// make sure the zIndex of the next card is higher than the one we're swiping away.
let nextIndexPath = IndexPath(row: itemIndexPath.row + 1, section: itemIndexPath.section)
let nextAttr = self.layoutAttributesForItem(at: nextIndexPath)
nextAttr?.zIndex = nextIndexPath.row
// attributes for swiping card away
let attr = self.layoutAttributesForItem(at: itemIndexPath)
return attr
}
/**
Updates the attributes.
Here manipulate the zIndex of the cards here, calculate the positions and do the animations.
- parameter attributes: The attributes we're updating.
*/
fileprivate func updateCellAttributes(_ attributes: UICollectionViewLayoutAttributes) {
let minY = collectionView!.bounds.minY + collectionView!.contentInset.top
let maxY = attributes.frame.origin.y
let finalY = max(minY, maxY)
var origin = attributes.frame.origin
let deltaY = (finalY - origin.y) / attributes.frame.height
if let itemTransform = firstItemTransform {
let scale = 1 - deltaY * itemTransform
attributes.transform = CGAffineTransform(scaleX: scale, y: scale)
// TODO: add card stack effect (like Shazam)
}
origin.y = finalY
attributes.frame = CGRect(origin: origin, size: attributes.frame.size)
attributes.zIndex = attributes.indexPath.row
}
}
CardCell代码(UICollectionViewCell的子类):
import UIKit
/**
The CardCell that the user can swipe away. Based on `UICollectionViewCell`.
The cells will be recycled by the `VerticalCardSwiper`,
so don't forget to override `prepareForReuse` when needed.
*/
@objc open class CardCell: UICollectionViewCell {
internal weak var delegate: CardDelegate?
open override func layoutSubviews() {
self.layer.shouldRasterize = true
self.layer.rasterizationScale = UIScreen.main.scale
super.layoutSubviews()
}
open override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) {
super.apply(layoutAttributes)
self.layer.zPosition = CGFloat(layoutAttributes.zIndex)
}
open override func prepareForReuse() {
super.prepareForReuse()
// need to unhide a cell for reuse (cell is hidden when swiped away)
self.isHidden = false
}
/**
This function animates the card. The animation consists of a rotation and translation.
- parameter angle: The angle the card rotates while animating.
- parameter horizontalTranslation: The horizontal translation the card animates in.
*/
public func animateCard(angle: CGFloat, horizontalTranslation: CGFloat){
delegate?.didDragCard(cell: self, swipeDirection: determineCardSwipeDirection())
var transform = CATransform3DIdentity
transform = CATransform3DRotate(transform, angle, 0, 0, 1)
transform = CATransform3DTranslate(transform, horizontalTranslation, 0, 1)
self.layer.transform = transform
}
/**
Resets the CardCell back to the center of the VerticalCardSwiperView.
*/
public func resetToCenterPosition(){
let cardCenterX = self.frame.midX
let centerX = self.bounds.midX
let initialSpringVelocity = fabs(cardCenterX - centerX)/100
UIView.animate(withDuration: 0.5,
delay: 0,
usingSpringWithDamping: 0.6,
initialSpringVelocity: initialSpringVelocity,
options: .allowUserInteraction,
animations: { [weak self] in
self?.layer.transform = CATransform3DIdentity
})
}
/**
Called when the pan gesture is ended.
Handles what happens when the user stops swiping a card.
If a certain treshold of the screen is swiped, the `animateOffScreen` function is called,
if the threshold is not reached, the card will be reset to the center by calling `resetToCenterPosition`.
- parameter angle: The angle of the animation, depends on the direction of the swipe.
*/
internal func endedPanAnimation(angle: CGFloat){
let swipePercentageMargin = self.bounds.width * 0.4
let cardCenterX = self.frame.midX
let centerX = self.bounds.midX
// check for left or right swipe and if swipePercentageMargin is reached or not
if (cardCenterX < centerX - swipePercentageMargin || cardCenterX > centerX + swipePercentageMargin){
animateOffScreen(angle: angle)
} else {
self.resetToCenterPosition()
}
}
/**
Animates to card off the screen and calls the `willSwipeAway` and `didSwipeAway` functions from the `CardDelegate`.
- parameter angle: The angle that the card will rotate in (depends on direction). Positive means the card is swiped to the right, a negative angle means the card is swiped to the left.
*/
fileprivate func animateOffScreen(angle: CGFloat){
var transform = CATransform3DIdentity
let direction = determineCardSwipeDirection()
transform = CATransform3DRotate(transform, angle, 0, 0, 1)
switch direction {
case .Left:
transform = CATransform3DTranslate(transform, -(self.frame.width * 2), 0, 1)
break
case .Right:
transform = CATransform3DTranslate(transform, (self.frame.width * 2), 0, 1)
break
default: break
}
self.delegate?.willSwipeAway(cell: self, swipeDirection: direction)
UIView.animate(withDuration: 0.2, animations: { [weak self] in
self?.layer.transform = transform
}){ (completed) in
self.isHidden = true
self.delegate?.didSwipeAway(cell: self, swipeDirection: direction)
}
}
fileprivate func determineCardSwipeDirection() -> SwipeDirection {
let cardCenterX = self.frame.midX
let centerX = self.bounds.midX
if cardCenterX < centerX {
return .Left
} else if cardCenterX > centerX {
return .Right
} else {
return .None
}
}
}
您可以在github上找到它: VerticalCardSwiper
如果要在此答案中获取更多代码,请告诉我,然后我将其添加,但是它很多,因此访问存储库可能会更容易。
答案 1 :(得分:0)
您需要实现滑动手势和scrollView。在少数情况下这可能很棘手,设置低于条件将有助于
scrollView.panGestureRecognizer.requireGestureRecognizerToFail(< UISwipeGestureRecognizer instance>)
请参阅此内容以便更好地理解: