iOS Autolayout: Oddly expand-animation of UITextView inside an UIScrollView - uiscrollview

I'm trying to animate the height constraint of a UITextView inside a UIScrollView. When the user taps the "toggle" button, the text should appear in animation from top to bottom. But somehow UIKit fades in the complete view.
To ensure the "dynamic" height depending on the intrinsic content-size, I deactivate the height constraint set to zero.
#IBAction func toggle() {
layoutIfNeeded()
UIView.animate(withDuration: 0.6, animations: { [weak self] in
guard let self = self else {
return
}
if self.expanded {
NSLayoutConstraint.activate([self.height].compactMap { $0 })
} else {
NSLayoutConstraint.deactivate([self.height].compactMap { $0 })
}
self.layoutIfNeeded()
})
expanded.toggle()
}
The full code of this example is available on my GitHub Repo: ScrollAnimationExample

Reviewing your GitHub repo...
The issue is due to the view that is animated. You want to run .animate() on the "top-most" view in the hierarchy.
To do this, you can either create a new property of your ExpandableView, such as:
var topMostView: UIView?
and then set that property from your view controller, or...
To keep your class encapsulated, let it find the top-most view. Replace your toggle() func with:
#IBAction func toggle() {
// we need to run .animate() on the "top" superview
// make sure we have a superview
guard self.superview != nil else {
return
}
// find the top-most superview
var mv: UIView = self
while let s = mv.superview {
mv = s
}
// UITextView has subviews, one of which is a _UITextContainerView,
// which also has a _UITextCanvasView subview.
// If scrolling is disabled, and the TextView's height is animated to Zero,
// the CanvasView's height is instantly set to Zero -- so it disappears instead of animating.
// So, when the view is "expanded" we need to first enable scrolling,
// and then animate the height (to Zero)
// When the view is NOT expanded, we first disable scrolling
// and then animate the height (to its intrinsic content height)
if expanded {
textView.isScrollEnabled = true
NSLayoutConstraint.activate([height].compactMap { $0 })
} else {
textView.isScrollEnabled = false
NSLayoutConstraint.deactivate([height].compactMap { $0 })
}
UIView.animate(withDuration: 0.6, animations: {
mv.layoutIfNeeded()
})
expanded.toggle()
}

Related

Saving the current width of a NavigationSplitView sidebar in SwiftUI

I'm making a macOS app in SwiftUI with the new NavigationSplitView. If a user resizes the sidebar, I'd like that new width to be remembered and restored when the app next loads.
A preference can be read in like so...
#State private var width = UserDefaults.standard.float(forKey: "sidebarWidth")
...
NavigationSplitView {
...
}.navigationSplitViewColumnWidth(ideal: width)
But this isn't a binding, so the width isn't updated when it changes.
Is it possible to save the current sidebar width when it changes (or when the app closes), so that it might later be restored?
Many thanks!
This is a function to retrieve the width:
extension View {
#ViewBuilder func onWidthChange(_ action: #escaping (CGFloat) -> Void) -> some View {
self
.background(
GeometryReader { reader in
Color.clear
.onChange(of: reader.frame(in: .global).width) { newValue in
action(newValue)
}
}
)
}
}
Usage:
Text("Hi")
.onWidthChange { newWidth in
//save width
}

Reset offset, onTapGesture works, but onRotated does not work, why?

I want the view containing 2 rectangles floating in the bottom of the screen, whatever the orientation is portrait or landscape.
code is a test, when orientationDidChangeNotification happened, I Found UIDevice.current.orientation.isPortrait and UIScreen.main.bounds.height often have wrong value, why?
Anyway, test code is just reset offset = 0 in onRotated(). but it doesn't work; otherwise onTapGesture works fine.
Q1: Is it a wrong way for SwiftUI? SceneDelegate.orientationDidChangeNotification -> contentView.onRotated()?
Q2: why do UIDevice.current.orientation.isPortrait and UIScreen.main.bounds.height often have wrong value?
Q3: How to let a view float at the bottom of screen in both portrait and landscape?
let height: CGFloat = 100
struct TestView: View {
#State var offset = (UIScreen.main.bounds.height - height) / 2
var body: some View {
ZStack {
Text("+")
VStack(spacing: 0) {
Rectangle().fill(Color.blue)
Rectangle().fill(Color.red)
}
.frame(width: 100, height: height)
.offset(y: offset)
.onTapGesture {
self.offset = 0
}
}
}
func onRotated() {
// let isPortrait = UIDevice.current.orientation.isPortrait
offset = 0//(UIScreen.main.bounds.height - height) / 2
// print("\(isPortrait), screen height = \(UIScreen.main.bounds.height)")
}
}
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let contentView = TestView()
if let windowScene = scene as? UIWindowScene {
NotificationCenter.default.addObserver(forName: UIDevice.orientationDidChangeNotification, object: nil, queue: nil) { notification in
contentView.onRotated()
}
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
}
...
}
contentView is not a reference, it is a value, so you call .onRotated on own copy of contentView value that lives only within callback
let contentView = TestView()
if let windowScene = scene as? UIWindowScene {
NotificationCenter.default.addObserver(forName: UIDevice.orientationDidChangeNotification, object: nil, queue: nil) { notification in
contentView.onRotated() // local copy!!!
}
instead create listener for notification publisher inside TestView, so it can change self internally.
Moreover, it is not clear the intention but SwiftUI gives possibility to track size classes via EnvironmentValues.horizontalSizeClass and EnvironmentValues.verticalSizeClass which are automatically changed on device orientation, so it is possible to make your view layout depending on those environment values even w/o notification.
See here good example on how to use size classes

UIView height constraint animation, which has anchor connection with other views

Xcode swift 4
I have some views that are added dynamically in code one below another.
Each new view top anchor is connected to previous view bottom anchor.
And each view have a button that make view to expand/collapse with animation. Here is button code :
let fullHeight : CGFloat = 240
let smallHeight : CGFloat = 44
let currentHeigth = rootView.frame.size.height //I use this to get current height and understand expanded view or not
let heighCons = rootView.constraints.filter //I use this to deactivate current height Anchor constraint
{
$0.firstAttribute == NSLayoutAttribute.height
}
NSLayoutConstraint.deactivate(heighCons)
rootView.layoutIfNeeded()
if currentHeigth == smallHeight
{
rootView.heightAnchor.constraint(equalToConstant: fullHeight).isActive = true
rootView.setNeedsLayout()
UIView.animate(withDuration: 0.5)
{
rootView.layoutIfNeeded() //animation itself
}
}
else
{
rootView.heightAnchor.constraint(equalToConstant: smallHeight).isActive = true
rootView.setNeedsLayout()
UIView.animate(withDuration: 0.5)
{
rootView.layoutIfNeeded() //animation itself
}
}
This all works perfectly but i have a problem : view that below current expanding view changes it y position immediately with no animation. Its just jumping to previous view bottom anchor, that would be active after animation finish.
So my question is :
1) what is the right way to make height constraint animation, when views are connected to each other by bottom/top animation?
2) my goal is just to make a view that would expand/collapse on button click, maybe i should do it another way?
Here's an approach using Visiblity Gone Extension
extension UIView {
func visiblity(gone: Bool, dimension: CGFloat = 0.0, attribute: NSLayoutAttribute = .height) -> Void {
if let constraint = (self.constraints.filter{$0.firstAttribute == attribute}.first) {
constraint.constant = gone ? 0.0 : dimension
self.layoutIfNeeded()
self.isHidden = gone
}
}
}
Usage
expanview.visiblity(gone: true,dimension: 0)
Example
#IBOutlet weak var msgLabel: UILabel!
#IBOutlet weak var expanview: UIView!
#IBAction func toggleCollapisbleView(_ sender: UIButton) {
if sender.isSelected{
sender.isSelected = false
expanview.visiblity(gone: false,dimension: 128)
sender.setTitle("Collapse",for: .normal)
}
else{
sender.isSelected = true
expanview.visiblity(gone: true,dimension: 0)
sender.setTitle("Expand",for: .normal)
msgLabel.text = "Visiblity gone"
}
}

How does one manually tell NSTextContainer to recalculate the lineFragmentRects?

In my application, I have been adding support for NSTextView allowing subviews to work naturally with the text editor. (For example, pressing "enter" drops every subview lower than the insertion point, and text wraps around subviews.)
I have successfully been able to get word wrap around subviews by overridinglineFragmentRectForProposedRect(proposedRect: NSRect,
sweepDirection: NSLineSweepDirection,
movementDirection: NSLineMovementDirection,
remainingRect: NSRectPointer) -> NSRect.
However, when a subview is dragged, or resized, the breaks in the text need to be recalculated to get the desired effect. The text needs to update itself to wrap around the subview as it is dragged or resized. However, I noticed that typing on a line updates the lineFragmentRect for that line. Is there a way to immediately mark the text as "dirty" after the subview is dragged? Here is the code for the text container subclass:
import Cocoa
class CASTextContainer: NSTextContainer {
var mainView: CASTextView = CASTextView()
var textSubviews: Array<AnyObject> {
get {
return mainView.subviews
}
}
override var simpleRectangularTextContainer: Bool {
get {
return false
}
}
func rectForActiveRange() -> NSRect {
let range = layoutManager.glyphRangeForCharacterRange(textView.selectedRange, actualCharacterRange:nil)
var rect = layoutManager.boundingRectForGlyphRange(range, inTextContainer: self)
rect = NSOffsetRect(rect, textView.textContainerOrigin.x, textView.textContainerOrigin.y)
return rect;
}
override func lineFragmentRectForProposedRect(proposedRect: NSRect,
sweepDirection: NSLineSweepDirection,
movementDirection: NSLineMovementDirection,
remainingRect: NSRectPointer) -> NSRect
{
remainingRect.initialize(NSZeroRect)
var frames: Array<NSRect> = []
if textSubviews.isEmpty {
let fullRect = NSMakeRect(0, 0, containerSize.width, containerSize.height)
return NSIntersectionRect(fullRect, proposedRect)
}
for view in textSubviews {
frames.append(view.frame)
}
// Sort the array so that the frames are ordered by increasing origin x coordinate.
let fullRect = NSMakeRect(0, 0, containerSize.width, containerSize.height)
let region = NSIntersectionRect(fullRect, proposedRect)
let sortedFrames: Array<NSRect> = sorted(frames, { (first: NSRect, second: NSRect) -> Bool in
return first.origin.x < second.origin.x
})
// Get the first rectangle height that overlaps with the text region's height.
var textDrawingRegion = NSZeroRect
var subviewFrame = NSZeroRect // Will be needed later to set remainingRect pointer.
var precedingRegion: NSRect = NSZeroRect // Hold the view frame preceding our insertion point.
for frame in sortedFrames {
if region.origin.y + NSHeight(region) >= frame.origin.y &&
region.origin.y <= frame.origin.y + NSHeight(frame)
{
if region.origin.x <= frame.origin.x {
// Calculate the distance between the preceding subview and the approaching one.
var width: CGFloat = frame.origin.x - precedingRegion.origin.x - NSWidth(precedingRegion)
textDrawingRegion.origin = region.origin
textDrawingRegion.size = NSMakeSize(width, NSHeight(region))
subviewFrame = frame
break
}
else {
precedingRegion = frame
}
}
}
if textDrawingRegion == NSZeroRect {
return region
}
else {
// Set the "break" in the text to the subview's location:
let nextRect = NSMakeRect(subviewFrame.origin.x + NSWidth(subviewFrame),
region.origin.y,
NSWidth(proposedRect),
NSHeight(proposedRect))
remainingRect.initialize(nextRect)
return textDrawingRegion
}
}
}

Swift - UIPanRecognizer in a UIStackView

I want to make a UIPanRecognizer in a UIStackView. The UI looks like this.
There's a big square which is a StackView which contains 9 labels.
My goal is when I start to drag my finger within the StackView and if a label contains that touchpoint then its color should be blue and on and on. If I lift my finger all of the labels background color turn back to white.
The labels are in a OutletCollection.
Here's my code:
#IBOutlet var testLabels: [UILabel]!
#IBAction func handlePan(recognizer:UIPanGestureRecognizer) {
var touchedPoint: CGPoint!
if let view = recognizer.view{
if recognizer.state == .began || recognizer.state == .changed {
touchedPoint = recognizer.location(in: view)
for index in testLabels.indices {
if testLabels[index].frame.contains(touchedPoint) {
testLabels[index].backgroundColor = UIColor.blue
}
}
}
if recognizer.state == .ended {
for index in testLabels.indices {
testLabels[index].backgroundColor = UIColor.white
}
}
}
Right now when I touch for example the top-left square and start to drag my finger to the right the whole first column turns blue then the second then the third. That happens when I touch any labels of the first row, but nothing happens when I touch the other squares.
First time, I tried to solve my problem by handle the squares just as an OutletCollection (without the StackView) and the recognizer just worked fine! The reason why I experiment with the StackView that it's so easier to make the layout(just a few constraints needed, without the StackView it is a nightmare).
I'd appreciate any help. Thank you.
Edit: probably this one helps?
Access nested stack views
Each label is a subview of its containing stack view (which is also probably a subview of a stack view), and each label has its own coordinate space.
Assuming your UIPanGestureRecognizer is assigned to the view controller's view, you need to translate the coordinates / frames.
This should work for you:
#IBAction func handlePan(recognizer:UIPanGestureRecognizer) {
var touchedPoint: CGPoint!
if let view = recognizer.view{
if recognizer.state == .began || recognizer.state == .changed {
touchedPoint = recognizer.location(in: view)
for index in testLabels.indices {
// translate / convert the label's bounds from its frame to the view's coordinate space
let testFrame = view.convert(testLabels[index].bounds, from:testLabels[index] )
// check the resulting testFrame for the point
if testFrame.contains(touchedPoint) {
testLabels[index].backgroundColor = UIColor.blue
}
}
}
else if recognizer.state == .ended {
for index in testLabels.indices {
testLabels[index].backgroundColor = UIColor.white
}
}
}
}
You can also simplify your code a bit by using for object in collection instead of indices. This is functionally the same as above, but maybe a little simpler:
#IBAction func handlePan(recognizer:UIPanGestureRecognizer) {
var touchedPoint: CGPoint!
if let view = recognizer.view{
if recognizer.state == .began || recognizer.state == .changed {
touchedPoint = recognizer.location(in: view)
for lbl in testLabels {
// translate / convert the label's bounds from its frame to the view's coordinate space
let testFrame = view.convert(lbl.bounds, from:lbl )
// check the resulting testFrame for the point
if testFrame.contains(touchedPoint) {
lbl.backgroundColor = .blue
}
}
}
else if recognizer.state == .ended {
for lbl in testLabels {
lbl.backgroundColor = .white
}
}
}
}

Resources