Decreasing number of cells causes NSInternalInconsistencyException exception - swift

I've been working from a while on the custom layouts for UICollectionView's. So far so good I've manage to place a header on the left hand side, made it sticky to avoid scrolling for him and also I've made a zoom in-out algorithm.
In my custom UICollectionView I have UIPinchGestureRecognizer which tell us if we zoomed in/out.
let pinchRecognizer = UIPinchGestureRecognizer(target: self, action: #selector(zoom(_:)))
pinchRecognizer.delegate = self
isUserInteractionEnabled = true
collectionView.addGestureRecognizer(pinchRecognizer)
func zoom(_ gesture: UIPinchGestureRecognizer) {
let scale = gesture.scale
if gesture.state == .ended {
if scale > 1 {
self.lastZoomScale += self.zoomScale
} else if scale < 1 {
self.lastZoomScale -= self.zoomScale
}
}
collectionView.performBatchUpdates({
if scale > 1 {
self.flowLayout.isZoomed = true
self.zoomScale = scale * 4
// If last zoom is equal 0 then user does not zoom it yet.
if self.lastZoomScale == 0 {
// Assign current zoom.
self.flowLayout.previousCellCount = Int(self.zoomScale)
self.flowLayout.numberOfCells = Int(self.zoomScale)
} else {
// User did scroll before and max of zooming scale might be 24 becouse of 24 hours.
if self.lastZoomScale > 24 {
self.lastZoomScale = 24
}
self.flowLayout.previousCellCount = Int(self.lastZoomScale)
self.flowLayout.numberOfCells = Int(self.lastZoomScale)
}
} else if scale < 1 {
self.zoomScale = scale * 4
// User did not zoom in then lets mark it as a default number of cells.
if self.lastZoomScale == 0 {
self.flowLayout.numberOfCells = 4
} else {
let scrollingDifference = self.lastZoomScale - self.zoomScale
if scrollingDifference > 4 {
self.flowLayout.previousCellCount = Int(self.lastZoomScale)
self.flowLayout.isZoomed = false
self.flowLayout.numberOfCells = Int(scrollingDifference)
} else {
self.flowLayout.numberOfCells = 4
}
}
}
}) { _ in
// TODO: Change size of cells while zoomed in/out.
// self.collectionView.performBatchUpdates({
// self.flowLayout.zoomMultiplier = 2
// })
}
}
From the first statement we can see that while the state is ended then increase lastZoomScale for aditional scale and also decrease while we zooming out back to oryginal scale.
Later on the UICollectionView have minimal 4 visible cells as an example and 24 maximum as following this. From the comments we can see what is going on. Also there is TODO in the completion statement, but we are not intrested into this.
Going thorugh the code time for the custom flow layout which looks like this :
import UIKit
class MyCollectionViewFlowLayout: UICollectionViewFlowLayout {
private var cellAttributes = [UICollectionViewLayoutAttributes]()
private var headerAttributes = [UICollectionViewLayoutAttributes]()
private var newIndexPaths = [IndexPath]()
private var removedIndexPaths = [IndexPath]()
private let numberOfSections = 4
private let headerWidth: CGFloat = 75
private let verticalDividerWidth: CGFloat = 1
private let horizontalDividerHeight: CGFloat = 1
fileprivate var contentSize: CGSize?
static let numberOfVisibleCells: Int = 4
var previousCellCount: Int = 0 {
willSet(newValue) {
self.previousCellCount = newValue
}
}
var numberOfCells: Int = 4 {
willSet(newValue) {
self.numberOfCells = newValue
} didSet {
invalidateLayout()
}
}
var zoomMultiplier: CGFloat = 1 {
willSet(newValue) {
self.zoomMultiplier = newValue
} didSet {
invalidateLayout()
}
}
var isZoomed: Bool = false {
willSet(newValue) {
self.isZoomed = newValue
}
}
override init() {
super.init()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem]) {
removedIndexPaths.removeAll()
super.prepare(forCollectionViewUpdates: updateItems)
var howManyToDelete = previousCellCount - numberOfCells
while howManyToDelete > 1 {
removedIndexPaths.append(cellAttributes.last!.indexPath)
howManyToDelete -= 1
}
}
override func prepare() {
scrollDirection = .horizontal
minimumLineSpacing = 1
minimumInteritemSpacing = 1
sectionHeadersPinToVisibleBounds = true
if collectionView == nil { return }
cellAttributes.removeAll()
headerAttributes.removeAll()
var cellX: CGFloat = 0
let xOffset = collectionView!.contentOffset.x
// Calculate the height of a row.
let availableHeight = collectionView!.bounds.height - collectionView!.contentInset.top - collectionView!.contentInset.bottom - CGFloat(numberOfSections - 1) * horizontalDividerHeight
let rowHeight = availableHeight / CGFloat(numberOfSections)
// Calculate the width available for time entry cells.
let itemsWidth: CGFloat = collectionView!.bounds.width - collectionView!.contentInset.left - collectionView!.contentInset.right - headerWidth
// For each section.
for i in 0..<numberOfSections {
// Y coordinate of the row.
let rowY = CGFloat(i) * (rowHeight + horizontalDividerHeight)
// Generate and store layout attributes header cell.
let headerIndexPath = IndexPath(item: 0, section: i)
let headerCellAttributes = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: UICollectionElementKindSectionHeader, with: headerIndexPath)
headerAttributes.append(headerCellAttributes)
headerCellAttributes.frame = CGRect(x: 0, y: rowY, width: headerWidth, height: rowHeight)
// Sticky header while scrolling.
headerCellAttributes.zIndex = 1
headerCellAttributes.frame.origin.x = xOffset
// TODO: Count of the cells for each section.
// Guesing it is going to be 24 = 24hs per day.
// Set the initial X position for cell.
cellX = headerWidth
// For each cell set a width.
for j in 0..<numberOfCells {
//Get the width of the cell.
var cellWidth = CGFloat(Double(itemsWidth) / Double(numberOfCells)) * zoomMultiplier
cellWidth -= verticalDividerWidth
cellX += verticalDividerWidth
// Generate and store layout attributes for the cell
let cellIndexPath = IndexPath(item: j, section: i)
let entryCellAttributes = UICollectionViewLayoutAttributes(forCellWith: cellIndexPath)
cellAttributes.append(entryCellAttributes)
entryCellAttributes.frame = CGRect(x: cellX, y: rowY, width: cellWidth, height: rowHeight)
cellX += cellWidth
}
}
contentSize = CGSize(width: cellX, height: collectionView!.bounds.height)
}
override var collectionViewContentSize: CGSize {
get {
if contentSize != nil {
return contentSize!
}
return .zero
}
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
// FIXME: Zoom-out crash.
return cellAttributes.first(where: { attributes -> Bool in
return attributes.indexPath == indexPath
})
}
override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return headerAttributes.first(where: { attributes -> Bool in
return attributes.indexPath == indexPath
})
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
var attributes = [UICollectionViewLayoutAttributes]()
for attribute in headerAttributes {
if attribute.frame.intersects(rect) {
attributes.append(attribute)
}
}
for attribute in cellAttributes {
if attribute.frame.intersects(rect) {
attributes.append(attribute)
}
}
print("Inside layout attribs \(cellAttributes.count)")
return attributes
}
override func indexPathsToDeleteForSupplementaryView(ofKind elementKind: String) -> [IndexPath] {
return removedIndexPaths
}
override func finalizeCollectionViewUpdates() {
removedIndexPaths.removeAll()
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
}
As you can see I've manage to paste a comments into the code.
My problem is that whenever I increase numberOfCells it is going to works like a charm, but whenever I am going to decrease number of them it is going to crash with an exception :
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'no UICollectionViewLayoutAttributes instance for -layoutAttributesForItemAtIndexPath: <NSIndexPath: 0xc000000001600016> {length = 2, path = 0 - 11}'
I've already deleted these indexes (prepare(forCollectionViewUpdates...), later on inside indexPathsToDeleteForSupplementaryView) and have no idea what is going on there.
Any one have an idea how could I fix this ?
Thank you !

Related

Hide/show CollectionView's Header on scroll

I want to hide my collectionView's Header cell when scrolled up. And show it again once the user scroll's down a bit. I've tried it using UICollectionViewFlowLayout but with no success.
class FilterHeaderLayout: UICollectionViewFlowLayout {
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let layoutAttributes = super.layoutAttributesForElements(in: rect)
var safeAreaTop = 0.0
if #available(iOS 13.0, *) {
let window = UIApplication.shared.windows.first
safeAreaTop = window!.safeAreaInsets.top
}
layoutAttributes?.forEach({ attributes in
if attributes.representedElementKind == UICollectionView.elementKindSectionHeader{
guard let collectionView = collectionView else { return }
let width = collectionView.frame.width
let contentOfsetY = collectionView.contentOffset.y
if contentOfsetY < 0 {
//prevents header cell to drift away if user scroll all the way down
// 41 is height of a view in navigation bar. header cell needs to be below navigation bar.
attributes.frame = .init(x: 0, y: safeAreaTop+41+contentOfsetY, width: width, height: 50)
return
}
let scrollVelocity = collectionView.panGestureRecognizer.velocity(in: collectionView.superview)
if (scrollVelocity.y > 0.0) {
// show the header and make it stay at top
} else if (scrollVelocity.y < 0.0) {
// hide the header
}
attributes.frame = .init(x: 0, y: safeAreaTop+41+contentOfsetY, width: width, height: 50)
}
})
return layoutAttributes
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
}
that's code is very old, but let's try:
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint,
withScrollingVelocity velocity: CGPoint) -> CGPoint {
var offsetAdjustment = CGFloat.greatestFiniteMagnitude
let verticalOffset = proposedContentOffset.y
let targetRect = CGRect(origin: CGPoint(x: 0, y: proposedContentOffset.y), size: self.collectionView!.bounds.size)
for layoutAttributes in super.layoutAttributesForElements(in: targetRect)! {
let itemOffset = layoutAttributes.frame.origin.y
if (abs(itemOffset - verticalOffset) < abs(offsetAdjustment)) {
offsetAdjustment = itemOffset - verticalOffset
}
}
return CGPoint(x: proposedContentOffset.x, y: proposedContentOffset.y + offsetAdjustment)
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let attributesArray = super.layoutAttributesForElements(in: rect)
attributesArray?.forEach({ (attributes) in
var length: CGFloat = 0
var contentOffset: CGFloat = 0
var position: CGFloat = 0
let collectionView = self.collectionView!
if (self.scrollDirection == .horizontal) {
length = attributes.size.width
contentOffset = collectionView.contentOffset.x
position = attributes.center.x - attributes.size.width / 2
} else {
length = attributes.size.height
contentOffset = collectionView.contentOffset.y
position = attributes.center.y - attributes.size.height / 2
}
if (position >= 0 && position <= contentOffset) {
let differ: CGFloat = contentOffset - position
let alphaFactor: CGFloat = 1 - differ / length
attributes.alpha = alphaFactor
attributes.transform3D = CATransform3DMakeTranslation(0, differ, 0)
} else if (position - contentOffset > collectionView.frame.height - Layout.model.height - Layout.model.spacing
&& position - contentOffset <= collectionView.frame.height) {
let differ: CGFloat = collectionView.frame.height - position + contentOffset
let alphaFactor: CGFloat = differ / length
attributes.alpha = alphaFactor
attributes.transform3D = CATransform3DMakeTranslation(0, differ - length, 0)
attributes.zIndex = -1
} else {
attributes.alpha = 1
attributes.transform = CGAffineTransform.identity
}
})
return attributesArray
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}

Swift UICollectionView Cell displayed/scrolled in to the view incorrectly when keyboard hiding animation change bottom inset

I am building a messanger view in iOs (Swift) app with help of UICollectionView inside a UIViewController. I am taking inspiration from MessageKit and I was able to set everything properly with simple dynamic cell height. When I hide the keyboard and the collection view bottom inset is reduced while the collection view is scrolled to bottom, it logically drags cells in to the view from top (scrolls down). I am not sure if it is somehow in conflict with keyboard hiding animation, but if this cause the collection view scroll to much and therefore display cells that have not been in the view, they appear not as scrolled in, but with some strange layout animation. It happens only while hiding keyboard && collectionView is at the bottom. Please check the gif:
link to gif
Building everything on UITableView did work, but I aim for collection view due to future features. I tried to use even a fixed cell height in a CollectionViewFlowDelegate, but it has the same effect as dynamically calculated heights.
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
I am setting the UICollectionView inset the same way as in the MessageKit:
private func requiredScrollViewBottomInset(forKeyboardFrame keyboardFrame: CGRect) -> CGFloat {
let intersection = chatCollectionView.frame.intersection(keyboardFrame)
if intersection.isNull || (chatCollectionView.frame.maxY - intersection.maxY) > 0.001 {
messagesCollectionView.frame.maxY when dealing with undocked keyboards.
return max(0, additionalBottomInset - automaticallyAddedBottomInset)
} else {
return max(0, intersection.height + additionalBottomInset - automaticallyAddedBottomInset)
}
}
#objc private func handleKeyboardDidChangeState(_ notification: Notification) {
guard !isMessagesControllerBeingDismissed else { return }
guard let keyboardStartFrameInScreenCoords = notification.userInfo?[UIResponder.keyboardFrameBeginUserInfoKey] as? CGRect else { return }
guard !keyboardStartFrameInScreenCoords.isEmpty || UIDevice.current.userInterfaceIdiom != .pad else {
// WORKAROUND for what seems to be a bug in iPad's keyboard handling in iOS 11: we receive an extra spurious frame change
// notification when undocking the keyboard, with a zero starting frame and an incorrect end frame. The workaround is to
// ignore this notification.
return
}
guard let keyboardEndFrameInScreenCoords = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
let keyboardEndFrame = view.convert(keyboardEndFrameInScreenCoords, from: view.window)
let newBottomInset = requiredScrollViewBottomInset(forKeyboardFrame: keyboardEndFrame)
let differenceOfBottomInset = newBottomInset - messageCollectionViewBottomInset
if maintainPositionOnKeyboardFrameChanged && differenceOfBottomInset >/*!=*/ 0 {
let contentOffset = CGPoint(x: chatCollectionView.contentOffset.x, y: chatCollectionView.contentOffset.y + differenceOfBottomInset)
chatCollectionView.setContentOffset(contentOffset, animated: false)
}
messageCollectionViewBottomInset = newBottomInset
}
internal func requiredInitialScrollViewBottomInset() -> CGFloat {
print("accessory view for initial bottom inset: \(inputAccessoryView)")
guard let inputAccessoryView = inputAccessoryView else { return 0 }
return max(0, inputAccessoryView.frame.height + additionalBottomInset - automaticallyAddedBottomInset)
}
As I could not find any related topic regarding this scrolling upon keyboard hiding, I am not sure if this is reusableCell issue or animation conflict?
EDIT
So the partial solution is to invalidate layout only if the width change, this will prevent keyboard invalidating it while hiding:
open override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return collectionView?.bounds.width != newBounds.width
}
But it also prevents sticky headers to get invalidated and therefore resign being sticky. I went deeper into invalidationContext as this looked as potential full solution, although I get the same behaviour.
open override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
invalidateLayout(with: invalidationContext(forBoundsChange: newBounds))
return collectionView?.bounds.width != newBounds.width
}
open override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
let context = super.invalidationContext(forBoundsChange: newBounds)
guard let flowLayoutContext = context as? UICollectionViewFlowLayoutInvalidationContext else { return context }
let indexes: [IndexPath] = (collectionView?.indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionHeader))!
print(indexes)
flowLayoutContext.invalidateSupplementaryElements(ofKind: UICollectionView.elementKindSectionHeader, at: indexes)
print(context.invalidatedSupplementaryIndexPaths)
return flowLayoutContext
}
Print statements clearly state that only headers are invalidated, and for the rest I am returning false. But it behaves exactly the same as in the gif (see the link - unfortunately I have reputation not high enough yet to add it directly here). Thank you for any comments!
After long investigation I noticed that the same phenomen happens also at the bottom in particular cases. I am not sure it it is bug or custom layout is necessary, but with the simple flow layout I solved the issue by setting collectionView contraints beyound the display edge.
This force to call displaying cell earlier and gives some time to lay cell approprietely even hide keyboard animation is used. Of coarse collectionView top and bottom contentInset has to be set in viewDidLoad and handle any contentInset change during runtime accordingly
Hope it help!
EDIT:
I ended up using CADisplaLink for scrolling of the collectionView when the keyboard shows and setting the bottom inset without animation. This case the inset to change exactly in the moment, when scroll function is scrolling the collection, so there is no jump and each frame has correct layout. For the keyboard hide it is quite simple - setting bottom inset in keyboardWillHide cause the collection to adjust offset upon releasing the finger also outside animation block and therefore is the layout also correct.
Following code is inspired by multiple answers here and by MessakeKit on GitHub
let scrollController = ScrollController()
override func viewDidLoad() {
super.viewDidLoad()
scrollController.scrollView = self.chatCollectionView
scrollController.decelerationAllowed = true
scrollController.keepUserInteractionDuringScroll = true
}
#objc private func keyboardWillShowOrHide(_ notification: Notification) {
guard !ignoreKeyboardNotification, !insertingMessage, !isViewControllerBeingDismissed, viewAlreadyAppeared else { return }
guard let keyboardEndFrameInScreenCoords = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
let keyboardEndFrame = view.convert(keyboardEndFrameInScreenCoords, from: view.window)
let newBottomInset = requiredScrollViewBottomInset(forKeyboardFrame: keyboardEndFrame)
let deltaBottomInset = newBottomInset - messageCollectionViewBottomInset
let differenceOfBottomInset = min(deltaBottomInset, collectionViewContentHightIntersectSafeArea())
ignoreScrollButtonHide = true
let scrollButtonOriginY = keyboardEndFrameInScreenCoords.origin.y - (self.scrollToBottomButtonMargin + self.scrollButton.frame.size.height)
UIView.performWithoutAnimation {
let contentOffset = chatCollectionView.contentOffset
self.messageCollectionViewBottomInset = newBottomInset
self.chatCollectionView.contentOffset = contentOffset
}
if !chatCollectionView.isTracking {
if differenceOfBottomInset != 0 {
let beginOffsetY = self.scrollController.endContentOffset == CGPoint.zero ? self.chatCollectionView.contentOffset.y : self.scrollController.endContentOffset.y
let negativeScroll = differenceOfBottomInset < 0//if difference is negative, it should not scroll pass the topScrollOffset
let setContentOffsetY = negativeScroll ? max(beginOffsetY + differenceOfBottomInset, topScrollOffset()) : beginOffsetY + differenceOfBottomInset
let contentOffset = CGPoint(x: 0.0, y: setContentOffsetY)
self.scrollController.setContentOffset(contentOffset, with: .easeOut, duration: self.animationsDuration)
}
}
UIView.animate(withDuration: 0, delay: 0, options: [.curveEaseOut, .allowAnimatedContent, .overrideInheritedOptions, .beginFromCurrentState], animations: {
self.chatCollectionView.verticalScrollIndicatorInsets.bottom = newBottomInset
self.scrollButton.frame.origin.y = scrollButtonOriginY
self.adjustLangSelectionViews()
}, completion: { (complete) in
self.ignoreScrollButtonHide = false
if self.chatCollectionView.isDecelerating == false { //do not hide if still scrolling
self.pinnedHeaderIsHidden(hidden: true)
}
})
}
I unfortunately did not saved, where I got the scrollController code for the CADisplayLink. If author could contact me, Iĺl be more than happy to put the link to the source here.
class ScrollController {
//CADisplayLink
private let scrollAnimationDuration: TimeInterval = 0.25
private let scrollApproximationTolerance: Double = 0.00000001
private let scrollMaximumSteps: Int = 10
var displayLink: CADisplayLink? // Display link used to trigger event to scroll the view
private var timingFunction: CAMediaTimingFunction? // Timing function of an scroll animation
private var duration: CFTimeInterval = 0 // Duration of an scroll animation
private var animationStarted = false // States whether the animation has started
private var beginTime: CFTimeInterval = 0 // Time at the begining of an animation
private var beginContentOffset = CGPoint.zero // The content offset at the begining of an animation
private var deltaContentOffset = CGPoint.zero // The delta between the scroll contentOffsets
var endContentOffset = CGPoint.zero // The delta between the scroll contentOffsets
var scrollView: UIScrollView!
var keepUserInteractionDuringScroll = false
var delegateClass: UIScrollViewDelegate?
var decelerationAllowed: Bool = false
func setContentOffset(_ contentOffset: CGPoint, with timingFunction: CAMediaTimingFunction?, duration: CFTimeInterval, deceleratingAllowed: Bool? = false) {
self.duration = duration
self.timingFunction = timingFunction
deltaContentOffset = CGPointMinus(contentOffset, scrollView.contentOffset)
endContentOffset = contentOffset
decelerationAllowed = deceleratingAllowed!
if displayLink == nil {
displayLink = CADisplayLink(target: self, selector: #selector(updateContentOffset(_:)))
displayLink?.add(to: .main, forMode: .common)
scrollView.isUserInteractionEnabled = keepUserInteractionDuringScroll
} else {
beginTime = 0.0
}
}
#objc func updateContentOffset(_ displayLink: CADisplayLink?) {
if beginTime == 0.0 {
beginTime = self.displayLink?.timestamp ?? 0
beginContentOffset = scrollView.contentOffset
} else {
let deltaTime = (displayLink?.timestamp ?? 0) - beginTime
// Ratio of duration that went by
let progress = CGFloat(deltaTime / duration)
if progress < 1.0 {
// Ratio adjusted by timing function
let adjustedProgress = CGFloat(timingFunctionValue(timingFunction, Double(progress)))
if 1 - adjustedProgress < 0.001 {
stopDisplayLink()
} else {
updateProgress(adjustedProgress)
}
} else {
stopDisplayLink()
}
}
}
func updateProgress(_ progress: CGFloat) {
let currentDeltaContentOffset = CGPointScalarMult(progress, deltaContentOffset)
scrollView.contentOffset = CGPointAdd(beginContentOffset, currentDeltaContentOffset)
}
func stopDisplayLink() {
displayLink?.isPaused = true
beginTime = 0.0
if !decelerationAllowed {
scrollView.setContentOffset(endContentOffset, animated: false)
} else {
scrollView.contentOffset = CGPointAdd(beginContentOffset, deltaContentOffset)
}
endContentOffset = CGPoint.zero
displayLink?.invalidate()
displayLink = nil
scrollView.isUserInteractionEnabled = true
if delegateClass != nil {
delegateClass!.scrollViewDidEndScrollingAnimation?(scrollView)
}
}
func CGPointScalarMult(_ s: CGFloat, _ p: CGPoint) -> CGPoint {
return CGPoint(x: s * p.x, y: s * p.y)
}
func CGPointAdd(_ p: CGPoint, _ q: CGPoint) -> CGPoint {
return CGPoint(x: p.x + q.x, y: p.y + q.y)
}
func CGPointMinus(_ p: CGPoint, _ q: CGPoint) -> CGPoint {
return CGPoint(x: p.x - q.x, y: p.y - q.y)
}
func cubicFunctionValue(_ a: Double, _ b: Double, _ c: Double, _ d: Double, _ x: Double) -> Double {
return (a*x*x*x)+(b*x*x)+(c*x)+d//Double(d as? c as? b as? a ?? 0.0)
}
func cubicDerivativeValue(_ a: Double, _ b: Double, _ c: Double, _ d: Double, _ x: Double) -> Double {
/// Derivation of the cubic (a*x*x*x)+(b*x*x)+(c*x)+d
return (3 * a * x * x) + (2 * b * x) + c
}
func rootOfCubic(_ a: Double, _ b: Double, _ c: Double, _ d: Double, _ startPoint: Double) -> Double {
// We use 0 as start point as the root will be in the interval [0,1]
var x = startPoint
var lastX: Double = 1
// Approximate a root by using the Newton-Raphson method
var y = 0
while y <= scrollMaximumSteps && fabs(lastX - x/*Float(lastX - x)*/) > scrollApproximationTolerance {
lastX = x
x = x - (cubicFunctionValue(a, b, c, d, x) / cubicDerivativeValue(a, b, c, d, x))
y += 1
}
return x
}
func timingFunctionValue(_ function: CAMediaTimingFunction?, _ x: Double) -> Double {
var a = [Float](repeating: 0.0, count: 2)
var b = [Float](repeating: 0.0, count: 2)
var c = [Float](repeating: 0.0, count: 2)
var d = [Float](repeating: 0.0, count: 2)
function?.getControlPoint(at: 0, values: &a)
function?.getControlPoint(at: 1, values: &b)
function?.getControlPoint(at: 2, values: &c)
function?.getControlPoint(at: 3, values: &d)
// Look for t value that corresponds to provided x
let t = rootOfCubic(Double(-a[0] + 3 * b[0] - 3 * c[0] + d[0]), Double(3 * a[0] - 6 * b[0] + 3 * c[0]), Double(-3 * a[0] + 3 * b[0]), Double(a[0]) - x, x)
// Return corresponding y value
let y = cubicFunctionValue(Double(-a[1] + 3 * b[1] - 3 * c[1] + d[1]), Double(3 * a[1] - 6 * b[1] + 3 * c[1]), Double(-3 * a[1] + 3 * b[1]), Double(a[1]), t)
return y
}
Hope it helps!

Smooth zoomable collection view

I am trying to figure out how could I achieve smooth zooming inside UICollectionView, so far I've got first step - It's zooming in/out but without greater control on which value I would like to stop it.
Here is my implementation of zoom in/out :
Default zooming lvl.
EDIT
Improved zooming algorythm.
var lastZoomLvl: CGFloat = 1 {
willSet(newValue) {
self.lastZoomLvl = newValue
}
}
// Zooming logic.
func zoom(scale: CGFloat) {
if scale > 1 {
lastZoomLvl += scale
} else {
lastZoomLvl -= scale
}
if lastZoomLvl < 1 {
lastZoomLvl = 1
} else if lastZoomLvl > 12 {
lastZoomLvl = 12
}
flowLayout.zoomMultiplier = lastZoomLvl
}
The call of zoom method is in VC with UICollectionView.
func zoom(_ gesture: UIPinchGestureRecognizer) {
let scale = gesture.scale
if gesture.state == .began {
chartCollectionView.isScrollEnabled = false
}
if gesture.state == .ended {
chartCollectionView.isScrollEnabled = true
if scale > 1 {
self.chart.lastZoomLvl += scale
}
}
chartCollectionView.performBatchUpdates({
self.chart.zoom(scale: scale)
}, completion: { _ in
// TODO: Maybe something in the future ?
})
}
As you can see I've achieve zooming by adding UIPinchGestureRecognizer and then using performBatchUpdates my layout is going to be updated by new cell sizes.
The layout implementation :
class ChartCollectionViewFlowLayout: UICollectionViewFlowLayout {
private var cellAttributes = [UICollectionViewLayoutAttributes]()
private var supplementaryAttributes = [UICollectionViewLayoutAttributes]()
private var newIndexPaths = [IndexPath]()
private var removedIndexPaths = [IndexPath]()
private let numberOfSections = 5
private var suplementaryWidth: CGFloat = 0
private let horizontalDividerHeight: CGFloat = 1
private var timeLineHeight: CGFloat = 0
fileprivate var contentSize: CGSize?
static let numberOfVisibleCells: Int = 4
// Calculate the width available for time entry cells.
var itemsWidth: CGFloat = 0 {
willSet(newValue) {
self.itemsWidth = newValue
}
}
var cellWidth: CGFloat = 0 {
willSet(newValue) {
self.cellWidth = newValue
}
}
var numberOfCells: Int = 24 {
willSet(newValue) {
self.numberOfCells = newValue
}
}
var zoomMultiplier: CGFloat = 1 {
willSet(newValue) {
self.zoomMultiplier = newValue
}
}
override init() {
super.init()
scrollDirection = .horizontal
sectionHeadersPinToVisibleBounds = true
sectionFootersPinToVisibleBounds = true
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func invalidateLayout() {
super.invalidateLayout()
supplementaryAttributes.removeAll()
cellAttributes.removeAll()
}
override func prepare() {
if collectionView == nil { return }
var cellX: CGFloat = 0
var rowY: CGFloat = 0
let xOffset = collectionView!.contentOffset.x
// Calculate the height of a row.
let availableHeight = collectionView!.bounds.height - collectionView!.contentInset.top - collectionView!.contentInset.bottom - CGFloat(numberOfSections - 1) * horizontalDividerHeight
if deviceIdiom == .pad {
suplementaryWidth = 75
timeLineHeight = 30
} else {
suplementaryWidth = 30
timeLineHeight = 25
}
itemsWidth = collectionView!.bounds.width - collectionView!.contentInset.left - collectionView!.contentInset.right - suplementaryWidth
var rowHeight = availableHeight / CGFloat(numberOfSections)
let differenceBetweenTimeLine = rowHeight - timeLineHeight
let toAddToRowY = differenceBetweenTimeLine / CGFloat(numberOfSections - 1)
rowHeight += toAddToRowY
// For each section.
for i in 0..<numberOfSections {
// Generate and store layout attributes header cell.
let headerIndexPath = IndexPath(item: 0, section: i)
let headerCellAttributes = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: UICollectionElementKindSectionHeader, with: headerIndexPath)
supplementaryAttributes.append(headerCellAttributes)
if i == 0 {
rowY = CGFloat(i) * (30 + horizontalDividerHeight)
headerCellAttributes.frame = CGRect(x: 0, y: rowY, width: suplementaryWidth, height: timeLineHeight)
} else {
rowY = CGFloat(i) * (rowHeight + horizontalDividerHeight) - (toAddToRowY * CGFloat(numberOfSections))
headerCellAttributes.frame = CGRect(x: 0, y: rowY, width: suplementaryWidth, height: rowHeight)
}
// Sticky header / footer while scrolling.
headerCellAttributes.zIndex = 1
headerCellAttributes.frame.origin.x = xOffset
// Set the initial X position for cell.
cellX = suplementaryWidth
// For each cell set a width.
for j in 0..<numberOfCells {
//Get the width of the cell.
cellWidth = CGFloat(Double(itemsWidth) / Double(numberOfCells)) * zoomMultiplier
// Generate and store layout attributes for the cell
let cellIndexPath = IndexPath(item: j, section: i)
let entryCellAttributes = UICollectionViewLayoutAttributes(forCellWith: cellIndexPath)
cellAttributes.append(entryCellAttributes)
if i == 0 {
entryCellAttributes.frame = CGRect(x: cellX, y: rowY, width: cellWidth, height: timeLineHeight)
} else {
entryCellAttributes.frame = CGRect(x: cellX, y: rowY, width: cellWidth, height: rowHeight)
}
cellX += cellWidth
}
}
contentSize = CGSize(width: cellX, height: collectionView!.bounds.height)
super.prepare()
}
override var collectionViewContentSize: CGSize {
get {
if contentSize != nil {
return contentSize!
}
return .zero
}
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return cellAttributes.first(where: { (attributes) -> Bool in
return attributes.indexPath == indexPath
})
}
override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return supplementaryAttributes.first(where: { (attributes) -> Bool in
return attributes.indexPath == indexPath
})
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
var attributes = [UICollectionViewLayoutAttributes]()
for attribute in supplementaryAttributes {
if attribute.frame.intersects(rect) {
attributes.append(attribute)
}
}
for attribute in cellAttributes {
if attribute.frame.intersects(rect) {
attributes.append(attribute)
}
}
return attributes
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
}
Layout let me to place the sticky header to the left hand side and the cell size is defined by cellWidth. I suppose that the size is growing exponentially, how could I get a better control ? Does any one have ideas how could I prove it ?
Thanks in advance!

Swift does not draw chart (XYPieChart)

I am using the XYPieChart library in order to draw a pie chart in my project but it doesn't draw the chart when I run it in the simulator. I tried it in a UITableViewController class but did not get result switched to UIViewController but got same results.
What am I doing wrong in here ?
import Foundation
import XYPieChart
class MainVC:UIViewController,XYPieChartDelegate,XYPieChartDataSource{
let z = Share.sharedInstance
let dbm = DatabaseManager()
var chart_dNameArr = [""]
var chart_dAmountArr = [0.0]
override func viewDidLoad() {
self.view.addGestureRecognizer(self.revealViewController().panGestureRecognizer())
makeChart()
}
func makeChart(){
let pieChart = XYPieChart()
let viewWidth: Float = Float(pieChart.bounds.size.width / 2)
let viewHeight: Float = Float(pieChart.bounds.size.height / 2)
pieChart.delegate = self
pieChart.dataSource = self
pieChart.startPieAngle = CGFloat(M_PI_2)
pieChart.animationSpeed = 1.5
pieChart.labelColor = UIColor.whiteColor()
pieChart.labelShadowColor = UIColor.blackColor()
pieChart.showPercentage = true
pieChart.backgroundColor = UIColor.whiteColor()
//To make the chart at the center of view
pieChart.pieCenter = CGPointMake(pieChart.bounds.origin.x + CGFloat(viewWidth), pieChart.bounds.origin.y + CGFloat(viewHeight))
//Method to display the pie chart with values.
pieChart.reloadData()
print("made a chart")
}
func numberOfSlicesInPieChart(pieChart: XYPieChart!) -> UInt {
return 2
}
func pieChart(pieChart: XYPieChart!, valueForSliceAtIndex index: UInt) -> CGFloat {
var value: CGFloat = 0.0
if index % 2 == 0 {
value = 25
}
else {
value = 75
}
return value
}
func pieChart(pieChart: XYPieChart!, colorForSliceAtIndex index: UInt) -> UIColor! {
var color: UIColor
if index % 2 == 0 {
color = UIColor.redColor()
}
else {
color = UIColor.greenColor()
}
return color
}
}
I think you did not add the piechart to the view
func makeChart(){
//....
pieChart.reloadData()
addSubView(pieChart) //<- Add this line
}

Moving multiple sprite nodes at once on swift

Can I make an array of SK nodes of which one is selected randomly and brought from the top to bottom of the screen. For example say I have 25 or so different platforms that will be falling out of the sky on a portrait iPhone. I need it to randomly select one of the platforms from the array to start and then after a certain amount of time/ or pixel space randomly select another to continue the same action until reaching the bottom etc. Im new to swift but have a pretty decent understanding of it. I haven't been able to find out how to create an array of SKsprite nodes yet either. Could someone help with this?
So far the only way I've been able to get any sort of effect similar to what I've wanted is by placing each of the nodes off the screen and adding them to a dictionary and making them move like this
class ObstacleStatus {
var isMoving = false
var timeGapForNextRun = Int(0)
var currentInterval = Int(0)
init(isMoving: Bool, timeGapForNextRun: Int, currentInterval: Int) {
self.isMoving = isMoving
self.timeGapForNextRun = timeGapForNextRun
self.currentInterval = currentInterval
}
func shouldRunBlock() -> Bool {
return self.currentInterval > self.timeGapForNextRun
}
and
func moveBlocks(){
for(blocks, ObstacleStatus) in self.blockStatuses {
var thisBlock = self.childNodeWithName(blocks)
var thisBlock2 = self.childNodeWithName(blocks)
if ObstacleStatus.shouldRunBlock() {
ObstacleStatus.timeGapForNextRun = randomNum()
ObstacleStatus.currentInterval = 0
ObstacleStatus.isMoving = true
}
if ObstacleStatus.isMoving {
if thisBlock?.position.y > blockMaxY{
thisBlock?.position.y -= CGFloat(self.fallSpeed)
}else{
thisBlock?.position.y = self.origBlockPosistionY
ObstacleStatus.isMoving = false
}
}else{
ObstacleStatus.currentInterval++
}
}
}
using this for the random function
func randomNum() -> Int{
return randomInt(50, max: 300)
}
func randomInt(min: Int, max:Int) -> Int {
return min + Int(arc4random_uniform(UInt32(max - min + 1)))
}
All this has been doing for me is moving the pieces down at random timed intervals often overlapping them, But increasing the min or max of the random numbers doesn't really have an affect on the actual timing of the gaps. I need to be able to specify a distance or time gap.
One of many possible solutions is to create a falling action sequence which calls itself recursively until no more platform nodes are left. You can control the mean "gap time" and the range of its random variation. Here is a working example (assuming the iOS SpriteKit game template):
import SpriteKit
extension Double {
var cg: CGFloat { return CGFloat(self) }
}
extension Int {
var cg: CGFloat { return CGFloat(self) }
}
func randomInt(range: Range<Int>) -> Int {
return range.startIndex + Int(arc4random_uniform(UInt32(range.endIndex - range.startIndex)))
}
extension Array {
func randomElement() -> Element? {
switch self.count {
case 0: return nil
default: return self[randomInt(0..<self.count)]
}
}
func apply<Ignore>(f: (T) -> (Ignore)) {
for e in self { f(e) }
}
}
class GameScene: SKScene {
var screenWidth: CGFloat { return UIScreen.mainScreen().bounds.size.width }
var screenHeight: CGFloat { return UIScreen.mainScreen().bounds.size.height }
let PlatformName = "Platform"
let FallenPlatformName = "FallenPlatform"
func createRectangularNode(#x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat) -> SKShapeNode {
let rect = CGRect(x: x, y: y, width: width, height: height)
let path = UIBezierPath(rect: rect)
let node = SKShapeNode(path: path.CGPath)
return node
}
func createPlatformNodes(numNodes: Int, atHeight: CGFloat) -> [SKShapeNode] {
var padding = 20.cg
let width = (screenWidth - padding) / numNodes.cg - padding
padding = (screenWidth - width * numNodes.cg) / (numNodes.cg + 1)
let height = width / 4
var nodes = [SKShapeNode]()
for x in stride(from: padding, to: numNodes.cg * (width + padding), by: width + padding) {
let node = createRectangularNode(x: x, y: atHeight, width: width, height: height)
node.fillColor = SKColor.blackColor()
node.name = PlatformName
nodes.append(node)
}
return nodes
}
func createFallingAction(#by: CGFloat, duration: NSTimeInterval, timeGap: NSTimeInterval, range: NSTimeInterval = 0) -> SKAction {
let gap = SKAction.waitForDuration(timeGap, withRange: range)
// let fall = SKAction.moveToY(toHeight, duration: duration) // moveToY appears to have a bug: behaves as moveBy
let fall = SKAction.moveByX(0, y: -by, duration: duration)
let next = SKAction.customActionWithDuration(0) { [unowned self]
node, time in
node.name = self.FallenPlatformName
self.fallNextNode()
}
return SKAction.sequence([gap, fall, next])
}
func fallNextNode() {
if let nextNode = self[PlatformName].randomElement() as? SKShapeNode {
let falling = createFallingAction(by: screenHeight * 0.7, duration: 1, timeGap: 2.5, range: 2) // mean time gap and random range
nextNode.runAction(falling)
} else {
self.children.apply { ($0 as? SKShapeNode)?.fillColor = SKColor.redColor() }
}
}
override func didMoveToView(view: SKView) {
self.backgroundColor = SKColor.whiteColor()
for platform in createPlatformNodes(7, atHeight: screenHeight * 0.8) {
self.addChild(platform)
}
fallNextNode()
}
}