I am trying to replicate a component written in UIKit using SwiftUI
It is a 'shadowed card container' that looks like this -
You see the shadow effect has an offset and height value to it
Trying to create this in SwiftUI I cannot get it quite right -
The UIKit view is handled via an extension -
public extension UIView {
func addShadow(offset: CGSize = .init(width: 0, height: 3), color: UIColor = .init(red: 0, green: 0, blue: 0, alpha: 0.16), radius: CGFloat = 2, opacity: Float = 1) {
layer.masksToBounds = false
layer.shadowOffset = offset
layer.shadowColor = color.cgColor
layer.shadowRadius = radius
layer.shadowOpacity = opacity
layer.shouldRasterize = true
layer.rasterizationScale = UIScreen.main.scale
}
}
On a view -
public class CardContainerView: UIView {
private var didlLayoutSubviews = false
public override init(frame: CGRect) {
super.init(frame: frame)
configureUI()
}
required init?(coder: NSCoder) {
return nil
}
public override func layoutSubviews() {
super.layoutSubviews()
guard !didlLayoutSubviews else { return }
didlLayoutSubviews = true
addShadow(
offset: .init(width: 0, height: 3),
color: .init(red: 0, green: 0, blue: 0, alpha: 0.16),
radius: 2
)
}
}
private extension CardContainerView {
func configureUI() {
layer.cornerRadius = 12
clipsToBounds = true
}
}
I have tried this in SwiftUI and come up with -
public struct CardView<Content>: View where Content: View {
private var content: () -> Content
public init(#ViewBuilder _ content: #escaping () -> Content) {
self.content = content
}
public var body: some View {
VStack(content: content)
.padding()
.frame(maxWidth: .infinity, minHeight: 100, alignment: .top)
.background(Color(.tertiarySystemBackground))
.cornerRadius(12)
.shadow(
color: Color(.init(red: 0, green: 0, blue: 0, alpha: 0.16)),
radius: 2
)
}
}
I cannot understand how to apply the height and offset values to the shadow in SwiftUI
You can apply the offset values to the x and y values of the shadow.
VStack(content: content)
.padding()
.frame(maxWidth: .infinity, minHeight: 100, alignment: .top)
.background(Color(.tertiarySystemBackground))
.cornerRadius(12)
.shadow(
color: Color(.init(red: 0, green: 0, blue: 0, alpha: 0.16)),
radius: 2,
y: 3 // This should give you the same effect
)
Related
I hope you are all doing good.
I'm very new to this community and I hope we can help each other grow.
I'm currently building a MacOS App with swiftUI and I have this capsule shape, that has a water animation inside. The water animation are just waves that move horizontally created with animatable data and path. My problem is, I have two other buttons on the screen, a play button and a stop button. They are supposed to start filling the capsule with the water and stop doing it respectively, which they do, but they are supposed to do it with an animation, and it's not.
Below is my code for further details. Thanks in advance.
GeometryReader { geometry in
VStack {
Spacer()
BreathingWave(progress: $progressValue, phase: phase)
.fill(Color(#colorLiteral(red: 0.14848575, green: 0.6356075406, blue: 0.5744615197, alpha: 1)))
.clipShape(Capsule())
.border(Color.gray, width: 3)
.frame(width: geometry.size.width / 12)
.onAppear {
withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
self.phase = .pi * 2
}
}
HStack {
Spacer()
Image(systemName: "play.circle.fill")
.resizable()
.renderingMode(.template)
.frame(width: 50, height: 50)
.foregroundColor(Color(#colorLiteral(red: 0.14848575, green: 0.6356075406, blue: 0.5744615197, alpha: 1)))
.scaleEffect(scalePlay ? 1.25 : 1)
.onHover(perform: { scalPlay in
withAnimation(.linear(duration: 0.1)) {
scalePlay = scalPlay
}
})
.onTapGesture {
withAnimation(
.easeInOut(duration: duration)
.repeatForever(autoreverses: true)) {
if(progressValue < 1) {
progressValue += 0.1
}
else {
progressValue = progressValue
}
}
}
Spacer()
Image(systemName: "stop.circle.fill")
.resizable()
.renderingMode(.template)
.frame(width: 50, height: 50)
.foregroundColor(Color(#colorLiteral(red: 0.14848575, green: 0.6356075406, blue: 0.5744615197, alpha: 1)))
.scaleEffect(scaleStop ? 1.25 : 1)
.onHover(perform: { scalStop in
withAnimation(.linear(duration: 0.1)) {
scaleStop = scalStop
}
})
.onTapGesture {
withAnimation(.linear) {
progressValue = 0.0
}
}
Spacer()
}
.padding(.bottom, 50)
}
}
And this is the code of the BreathingWave
struct BreathingWave: Shape {
#Binding var progress: Float
var applitude: CGFloat = 10
var waveLength: CGFloat = 20
var phase: CGFloat
var animatableData: CGFloat {
get { phase }
set { phase = newValue }
}
func path(in rect: CGRect) -> Path {
var path = Path()
let width = rect.width
let height = rect.height
let minWidth = width / 2
let progressHeight = height * (1 - CGFloat(progress))
path.move(to: CGPoint(x: 0, y: progressHeight))
for x in stride(from: 0, to: width + 5, by: 5) {
let relativeX = x / waveLength
let normalizedLength = (x - minWidth) / minWidth
let y = progressHeight + sin(phase + relativeX) * applitude * normalizedLength
path.addLine(to: CGPoint(x: x, y: y))
}
path.addLine(to: CGPoint(x: width, y: progressHeight))
path.addLine(to: CGPoint(x: width, y: height))
path.addLine(to: CGPoint(x: 0, y: height))
path.addLine(to: CGPoint(x: 0, y: progressHeight))
return path
}
}
The problem you are running into is simply that you never told BreathingWave how to animate for progress. You set a single dimension animation, when you really want it to animate in two dimensions. The fix is straightforward: use AnimatablePair to supply the variables to animatableData.
struct BreathingWave: Shape {
var applitude: CGFloat = 10
var waveLength: CGFloat = 20
// progress is no longer a Binding. Using #Binding never did anything
// as you were not changing the value to be used in the parent view.
var progress: Float
var phase: CGFloat
var animatableData: AnimatablePair<Float, CGFloat> { // <- AnimatablePair here
get { AnimatablePair(progress, phase) } // <- the getter returns the pair
set { progress = newValue.first // <- the setter sets each individually from
phase = newValue.second // the pair.
}
}
func path(in rect: CGRect) -> Path {
...
}
}
Lastly, you now call it like this:
BreathingWave(progress: progressValue, phase: phase)
One final thing. Please provide a Minimal Reproducible Example (MRE) in the future. There was a lot of code that was unnecessary to debug this, and I had to implementment the struct header to run it. Strip as much out as possible, but give us something that we can copy, paste and run.
I am a new player for swiftui dev.
And I want to write a page-down clock to study swiftui.
But I stuck on how to page-down a page, which means rotate the object around x axis.
And I know rotate3DEffect would generate strange result.
Here is my code:
Rectangle() // the view
.frame(width: 100, height: 100, alignment: .center)
.foregroundColor(.white)
.accessibilityAction {
let a = UIView(frame: CGRect(origin: CGPoint(x: 10,y: 10), size: CGSize(width: 1, height: 2)))
a.backgroundColor = .blue
let b = UIView(frame: CGRect(origin: CGPoint(x: 10,y: 10), size: CGSize(width: 1, height: 2)))
b.backgroundColor = .yellow
let c = UIView(frame: CGRect(origin: CGPoint(x: 10,y: 10), size: CGSize(width: -1, height: -2)))
c.backgroundColor = .red
rotation(first: a,back: b,solid: c)
}
func rotation<V>(first: V, back: V, solid: V) where V: UIView{
// rotationFront
// rotationBack
/*
| B|/C
| A|
*/
rotationFront(first: first)
rotationBack(back: back)
}
func rotationFront<V>(first: V) where V: UIView {
let animation = CABasicAnimation(keyPath: "transform.rotation.x")
animation.fromValue = (10/360)*Double.pi
animation.toValue = (355/360)*Double.pi
animation.duration = 1.0
animation.repeatCount = 0
first.layer.add(animation, forKey: "rotationBack")
}
func rotationBack<V>(back: V) where V: UIView {
let animation = CABasicAnimation(keyPath: "transform.rotation.x")
animation.fromValue = (10/360)*Double.pi
animation.toValue = (355/360)*Double.pi
animation.duration = 1.0
animation.repeatCount = 0
back.layer.add(animation, forKey: "rotationFront")
}
It does nothing. What should I do? Thanks a lot.
Hmm i feel you're missing the base of how swiftui works with views, and you patched some solutions from uikit to rotate things. I would suggest to watch some wwdc videos about swiftui or follow some base courses like the one that standford provide free on their youtube channel. Here a video that helps with your idea https://youtu.be/3krC2c56ceQ
I added a solution that i come up on the fly but i would suggest to follow the Standford video because it provide a more complete solution where they use the AnimationModifier to handle the change from face up to flipped.
To animate rotations you should use the .rotation3DEffect or .rotationEffect modifiers (it depends on the usecase).
BTW here the base idea:
flip to 90° using the .rotation3DEffect so the card is not visible on the x axis
change the number incrementing the value
flip the number so is not rotated with .scale
complete the flip of 180 so the number is visible with the incremented value
struct PageDownExampleView: View {
#State var counterx1: Int = 0
#State var progressx1: Double = 0
#State var flip: Bool = false
var body: some View {
VStack {
Text("\(counterx1)")
.font(.system(size: 70, weight: .bold))
.foregroundColor(.white)
.frame(width: 100, height: 100, alignment: .center)
.background(Color.black)
.scaleEffect(CGSize(width: 1.0,
height: flip ? -1 : 1.0))
.rotation3DEffect(.degrees(progressx1),
axis: (x: 1.0, y: 0.0, z: 0.0),
anchor: .center,
anchorZ: 0.0,
perspective: 0.5)
.padding(15)
.border(Color.black, width: 9)
Button(action: {
withAnimation {
progressx1 += -90
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
flip.toggle()
counterx1 += 1
withAnimation {
progressx1 += -90
}
}
}
}, label: {
HStack(spacing: 5) {
Image(systemName: "playpause.fill")
Text("Start / Stop".uppercased())
}
})
.padding()
.border(Color.black, width: 1)
}
}
}
Side note:
the uiviews you created are part of UIKit framework and you're creating view applying a transform rotation. The first error is how you try to use uikit components in swiftui (your missing how to connect the two frameworks, you can search for UIViewRepresentable for the topic), and accessibilityAction handles only actions, they don't show anything.
In my SwiftUI application, I have to use the makeUIView function to create and animate a "sonar" view for some reasons in a UIViewRepresentable. I just have a problem with an animation: when I move the app to the background and return to it, the animation stops and doesn't automatically restart. I've searched the web for several solutions, but I'm not sure how to fix this problem in my code.
import UIKit
import SwiftUI
import Foundation
struct SonarView: View {
var width: CGFloat
var height: CGFloat
struct TargetView: UIViewRepresentable {
var width: CGFloat
var height: CGFloat
func makeUIView(context: Context) -> some UIView {
// Circle
let circle = UIView()
circle.frame = CGRect.init(x: 2, y: 2, width: width, height: height)
circle.layer.cornerRadius = width / 2
circle.layer.backgroundColor = UIColor(named: "PrimaryLight")?.cgColor
circle.layer.borderWidth = 2
circle.layer.borderColor = UIColor(named: "PrimaryDark")?.cgColor
return circle
}
func updateUIView(_ uiView: UIViewType, context: Context) {
UIView.animate(withDuration: 1.75, delay: 0, options: [.repeat, .autoreverse], animations: {
uiView.subviews[0].transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
}, completion: nil)
}
}
var body: some View {
TargetView(
width: self.width,
height: self.height,
)
}
}
In SwiftUI part, I implement the sonar view simply like this:
...
SonarView(width: geometry.size.height, height: geometry.size.height)
.frame(width: geometry.size.height, height: geometry.size.height)
.opacity(self.targetOpacity ? 1.0 : .zero)
.onAppear {
self.isNotNavigatingAnimationDelay = true
withAnimation(Animation.easeOut(duration: 0.5).delay(0.5)) {
self.targetOpacity = true
}
}
.onDisappear {
self.targetOpacity = false
self.targetOffset = .zero
}
...
Use CABasicAnimation.
func updateUIView(_ uiView: UIViewType, context: Context) {
let basicAnimation = CABasicAnimation(keyPath: "transform.scale")
basicAnimation.toValue = 1.2
basicAnimation.duration = 1.75
basicAnimation.isRemovedOnCompletion = false
basicAnimation.repeatCount = .greatestFiniteMagnitude
basicAnimation.autoreverses = true
uiView.subviews[0].layer.add(basicAnimation, forKey: "transformanimation")
}
I want to create a half circular progress bar with gradient colour in iOS using swift. The minimum value of progress bar is 300 and maximum value of progress bar is 900. The current value of the progress bar is dynamic. I am attaching screenshot ans css for colour reference. Can someone please provide me a small working demo?
below is the CSS -
/* Layout Properties */
top: 103px;
left: 105px;
width: 165px;
height: 108px;
/* UI Properties */
background: transparent linear-gradient
(269deg, #32E1A0 0%, #EEED56 23%, #EFBF39 50%, #E59148 75%, #ED4D4D 100%)
0% 0% no-repeat padding-box;
opacity: 1;
you can create not same but similar progress bar with framework called
MKMagneticProgress
example code :-
import MKMagneticProgress
#IBOutlet weak var magProgress:MKMagneticProgress!
override func viewDidLoad() {
magProgress.setProgress(progress: 0.5, animated: true)
magProgress.progressShapeColor = UIColor.blue
magProgress.backgroundShapeColor = UIColor.yellow
magProgress.titleColor = UIColor.red
magProgress.percentColor = UIColor.black
magProgress.lineWidth = 10
magProgress.orientation = .top
magProgress.lineCap = .round
magProgress.title = "Title"
magProgress.percentLabelFormat = "%.2f%%"
}
i hope it will work ... :)
this could solve your problem in swiftui
import SwiftUI
struct ContentView: View {
#State var progressValue: Float = 0.3
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
#State private var degress: Double = -110
var body: some View {
VStack {
ZStack{
ProgressBar(progress: self.$progressValue)
.frame(width: 250.0, height: 250.0)
.padding(40.0).onReceive(timer) { _ in
withAnimation {
if progressValue < 0.8999996 {
progressValue += 0.0275
}
}
}
ProgressBarTriangle(progress: self.$progressValue).frame(width: 280.0, height: 290.0).rotationEffect(.degrees(degress), anchor: .bottom)
.offset(x: 0, y: -150).onReceive(timer) { input in
withAnimation(.linear(duration: 0.01).speed(200)) {
if degress < 110.0 {
degress += 10
}
print(degress)
}
}
}
Spacer()
}
}
struct ProgressBar: View {
#Binding var progress: Float
var body: some View {
ZStack {
Circle()
.trim(from: 0.3, to: 0.9)
.stroke(style: StrokeStyle(lineWidth: 12.0, lineCap: .round, lineJoin: .round))
.opacity(0.3)
.foregroundColor(Color.gray)
.rotationEffect(.degrees(54.5))
Circle()
.trim(from: 0.3, to: CGFloat(self.progress))
.stroke(style: StrokeStyle(lineWidth: 12.0, lineCap: .round, lineJoin: .round))
.fill(AngularGradient(gradient: Gradient(stops: [
.init(color: Color.init(hex: "ED4D4D"), location: 0.39000002),
.init(color: Color.init(hex: "E59148"), location: 0.48000002),
.init(color: Color.init(hex: "EFBF39"), location: 0.5999999),
.init(color: Color.init(hex: "EEED56"), location: 0.7199998),
.init(color: Color.init(hex: "32E1A0"), location: 0.8099997)]), center: .center))
.rotationEffect(.degrees(54.5))
VStack{
Text("824").font(Font.system(size: 44)).bold().foregroundColor(Color.init(hex: "314058"))
Text("Great Score!").bold().foregroundColor(Color.init(hex: "32E1A0"))
}
}
}
}
struct ProgressBarTriangle: View {
#Binding var progress: Float
var body: some View {
ZStack {
Image("triangle").resizable().frame(width: 10, height: 10, alignment: .center)
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
extension Color {
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 3: // RGB (12-bit)
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6: // RGB (24-bit)
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: // ARGB (32-bit)
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (1, 1, 1, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}
I implemented something like your progress bar,
used this for level up - gain exp. progress.,
if you go from 300 to 1000, total bar is 1500
progressFrom: 300/1500, progressTo: 1000/1500 ..
func progressAnimation(duration: TimeInterval, progressFrom: CGFloat, progressTo: CGFloat) {
if progressFrom == 0 {
baseProgressLayer.removeFromSuperlayer()
}
let circlePath = UIBezierPath(ovalIn: CGRect(center: CGPoint(x: 93.0, y: 93.0), size: CGSize(width: 178, height: 178)))
progressLayer.path = circlePath.cgPath
progressLayer.fillColor = UIColor.clear.cgColor
progressLayer.strokeColor = getColor(progress: progressTo).cgColor
progressLayer.lineWidth = 8.0
layer.addSublayer(progressLayer)
layer.transform = CATransform3DMakeRotation(CGFloat(90 * Double.pi / 180), 0, 0, -1)
let end = CABasicAnimation(keyPath: "strokeEnd")
end.fromValue = progressFrom
end.toValue = progressTo
end.duration = duration * 0.85
end.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear)
end.fillMode = CAMediaTimingFillMode.forwards
end.isRemovedOnCompletion = false
let color = CABasicAnimation(keyPath: "strokeColor")
color.fromValue = getColor(progress: progressFrom).cgColor
color.toValue = getColor(progress: progressTo).cgColor
color.duration = duration * 0.85
color.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear)
color.fillMode = CAMediaTimingFillMode.forwards
color.isRemovedOnCompletion = false
let group = CAAnimationGroup()
group.animations = [end, color]
group.duration = duration * 0.85
progressLayer.strokeStart = progressFrom
progressLayer.strokeEnd = progressTo
progressLayer.add(group, forKey: "move")
if progressFrom != 0 {
let color2 = CABasicAnimation(keyPath: "strokeColor")
color2.fromValue = getColor(progress: progressFrom).cgColor
color2.toValue = getColor(progress: progressTo).cgColor
color2.duration = duration * 0.85
color2.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear)
color2.fillMode = CAMediaTimingFillMode.forwards
color2.isRemovedOnCompletion = false
baseProgressLayer.add(color2, forKey: "baseColor")
}
}
there is a extension for getting color for progress
extension CircularProgressBar {
private func getColor (progress: CGFloat) -> UIColor{
if progress >= 0 && progress <= 0.5{
return UIColor(red: CGFloat(255 / 255), green: CGFloat((0+((108-0)*progress*2)) / 255), blue: 0, alpha: 1.0)
}
else if progress > 0.5 && progress <= 1.0 {
return UIColor(red: CGFloat((255-((255-126)*(progress-0.5)*2)) / 255), green: CGFloat((108+((211-108)*(progress-0.5)*2)) / 255), blue: CGFloat((0+((33-0)*(progress-0.5)*2)) / 255), alpha: 1.0)
}
else {
return UIColor.green
}
}
}
I am trying to make a UIButton with rounded corners that has 2 colored shadows. Why is the red (and at this point also the blue "shadow" layer covering the button? How to get the shadows below the button canvas). I thought it was helping to insert sublayers instead of just adding them.
I have made a playground illustrating the issue
import UIKit
import PlaygroundSupport
This is the button I'm trying to implement
class PrimaryButton: UIButton {
required init(text: String = "Test 1", hasShadow: Bool = true) {
super.init(frame: .zero)
setTitle(text, for: .normal)
backgroundColor = UIColor.blue
layer.cornerRadius = 48 / 2
layer.masksToBounds = false
if hasShadow {
insertShadow()
}
}
fileprivate func insertShadow() {
let layer2 = CALayer(layer: layer), layer3 = CALayer(layer: layer)
layer2.applySketchShadow(color: UIColor.red, alpha: 0.5, x: 0, y: 15, blur: 35, spread: -10)
layer3.applySketchShadow(color: UIColor.blue, alpha: 0.5, x: 0, y: 10, blur: 21, spread: -9)
layer.insertSublayer(layer2, at: 0)
layer.insertSublayer(layer3, at: 0)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
layer.sublayers?.forEach { (sublayer) in
sublayer.shadowPath = UIBezierPath(rect: bounds).cgPath
}
}
}
This is an extension that helps adding the shadow from Sketch specification:
extension CALayer {
func applySketchShadow(
color: UIColor = .black,
alpha: Float = 0.5,
x: CGFloat = 0,
y: CGFloat = 2,
blur: CGFloat = 4,
spread: CGFloat = 0)
{
shadowColor = color.cgColor
shadowOpacity = alpha
shadowOffset = CGSize(width: x, height: y)
shadowRadius = blur / 2.0
if spread == 0 {
shadowPath = nil
} else {
let dx = -spread
let rect = bounds.insetBy(dx: dx, dy: dx)
shadowPath = UIBezierPath(rect: rect).cgPath
}
masksToBounds = false
}
}
class MyViewController : UIViewController {
override func loadView() {
let view = UIView()
view.backgroundColor = .white
let button = PrimaryButton()
button.frame = CGRect(x: 150, y: 200, width: 200, height: 48)
view.addSubview(button)
self.view = view
}
}
// Present the view controller in the Live View window
PlaygroundPage.current.liveView = MyViewController()
It seems legit to me. layer1 & layer2 are sublayers of the button layer.
You could add a third layer that will serve as a background. Here is an example based on your code:
class PrimaryButton: UIButton {
let layer1 = CALayer(), layer2 = CALayer(), layer3 = CALayer()
override func layoutSubviews() {
super.layoutSubviews()
layer1.backgroundColor = UIColor.blue.cgColor
layer1.cornerRadius = 48 / 2
[layer1, layer2, layer3].forEach {
$0.masksToBounds = false
$0.frame = layer.bounds
layer.insertSublayer($0, at: 0)
}
layer2.applySketchShadow(color: UIColor.red, alpha: 0.5, x: 0, y: 15, blur: 35, spread: -10)
layer3.applySketchShadow(color: UIColor.blue, alpha: 0.5, x: 0, y: 10, blur: 21, spread: -9)
}
}
Note that I put most of the code inside layoutSubviews because most of your methods use the actual bounds of the button.
Change your insertions to:
layer.insertSublayer(layer2, at: 1)
layer.insertSublayer(layer3, at: 2)
That should do it.
Another way is to add double buttons without change your class.
let button = PrimaryButton()
button.frame = CGRect(x: 150, y: 200, width: 200, height: 48)
button.backgroundColor = UIColor.clear
view.addSubview(button)
self.view = view
let button1 = PrimaryButton()
button1.frame = CGRect(x: 0, y: 0, width: 200, height: 48)
button.addSubview(button1)
button1.layer.sublayers?.forEach{$0.removeFromSuperlayer()}