From 8955223e0aa5ec5c9e4ef3132fbd56e129004e4a Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sat, 1 Mar 2025 22:38:56 +1100 Subject: [PATCH] Add flip --- README.md | 8 +- image-flow/ClockPreviewView.swift | 117 +++++++++++++++------------- image-flow/ClockStyle.swift | 6 +- image-flow/FlipClockViewModel.swift | 35 +++++++++ image-flow/FlipView.swift | 33 ++++++++ image-flow/FlipViewModel.swift | 30 +++++++ image-flow/SingleFlipView.swift | 60 ++++++++++++++ image-flow/SlideshowView.swift | 107 +++++++++---------------- 8 files changed, 265 insertions(+), 131 deletions(-) create mode 100644 image-flow/FlipClockViewModel.swift create mode 100644 image-flow/FlipView.swift create mode 100644 image-flow/FlipViewModel.swift create mode 100644 image-flow/SingleFlipView.swift diff --git a/README.md b/README.md index 3c58702..d62eac8 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A beautiful iOS app that combines your photos with stylish clock displays for a ## Features - Select multiple photos from your photo library -- Choose from different clock styles (Digital, Analog, Minimal) +- Choose from different clock styles (Digital, Flip, Minimal) - Select transition animations between photos (Fade, Slide, Zoom) - Fullscreen slideshow mode with automatic photo transitions every 10 seconds - Tap to exit fullscreen mode @@ -27,7 +27,7 @@ A beautiful iOS app that combines your photos with stylish clock displays for a 1. Launch the app 2. Tap "Select Photos" to choose images from your photo library -3. Select a clock style (Digital, Analog, or Minimal) +3. Select a clock style (Digital, Flip, or Minimal) 4. Choose a transition animation (Fade, Slide, or Zoom) 5. Tap "Start Slideshow" to enter fullscreen mode 6. Tap anywhere on the screen to exit fullscreen mode @@ -35,3 +35,7 @@ A beautiful iOS app that combines your photos with stylish clock displays for a ## Privacy The app requires access to your photo library to display selected images in the slideshow. Photos are only accessed within the app and are not shared or stored externally. + + +## Credits +* https://github.com/elpassion/FlipClock-SwiftUI \ No newline at end of file diff --git a/image-flow/ClockPreviewView.swift b/image-flow/ClockPreviewView.swift index 4ef8b69..ca1ce7e 100644 --- a/image-flow/ClockPreviewView.swift +++ b/image-flow/ClockPreviewView.swift @@ -24,8 +24,8 @@ struct ClockPreviewView: View { switch clockStyle { case .digital: digitalPreview - case .analog: - analogPreview + case .flip: + flipPreview case .minimal: minimalPreview } @@ -37,62 +37,69 @@ struct ClockPreviewView: View { } private var digitalPreview: some View { - VStack(spacing: 2) { - Text(timeFormatter.string(from: currentTime)) - .font(.system(size: 28, weight: .bold, design: .rounded)) - .foregroundColor(.primary) - - Text(dateFormatter.string(from: currentTime)) - .font(.system(size: 12, weight: .medium, design: .rounded)) - .foregroundColor(.secondary) - } + Text(minimalTimeFormatter.string(from: currentTime)) + .font(.system(size: 28, weight: .bold, design: .rounded)) + .foregroundColor(.primary) + .padding(8) + .background(Color.black.opacity(0.1)) + .cornerRadius(8) } - private var analogPreview: some View { - ZStack { - // Clock face - Circle() - .fill(Color.white) - .frame(width: 70, height: 70) - .shadow(radius: 2) - - // Hour markers - ForEach(0..<12) { hour in + private var flipPreview: some View { + HStack(spacing: 8) { + // Hours + VStack { + ZStack { + RoundedRectangle(cornerRadius: 4) + .fill(Color.black) + .frame(width: 30, height: 30) + + Text("\(String(format: "%02d", calendar.component(.hour, from: currentTime)))") + .font(.system(size: 22, weight: .bold)) + .foregroundColor(.white) + } + Rectangle() - .fill(Color.black) - .frame(width: 2, height: hour % 3 == 0 ? 6 : 3) - .offset(y: -30) - .rotationEffect(.degrees(Double(hour) * 30)) + .fill(Color.gray.opacity(0.5)) + .frame(height: 1) + + ZStack { + RoundedRectangle(cornerRadius: 4) + .fill(Color.black) + .frame(width: 30, height: 30) + + Text("\(String(format: "%02d", calendar.component(.hour, from: currentTime)))") + .font(.system(size: 22, weight: .bold)) + .foregroundColor(.white) + } } - // Hour hand - Rectangle() - .fill(Color.black) - .frame(width: 2, height: 20) - .cornerRadius(1) - .offset(y: -10) - .rotationEffect(.degrees(Double(hour) * 360)) - - // Minute hand - Rectangle() - .fill(Color.black) - .frame(width: 1.5, height: 28) - .cornerRadius(0.75) - .offset(y: -14) - .rotationEffect(.degrees(Double(minute) * 360)) - - // Second hand - Rectangle() - .fill(Color.red) - .frame(width: 1, height: 30) - .cornerRadius(0.5) - .offset(y: -15) - .rotationEffect(.degrees(Double(second) * 360)) - - // Center cap - Circle() - .fill(Color.black) - .frame(width: 4, height: 4) + // Minutes + VStack { + ZStack { + RoundedRectangle(cornerRadius: 4) + .fill(Color.black) + .frame(width: 30, height: 30) + + Text("\(String(format: "%02d", calendar.component(.minute, from: currentTime)))") + .font(.system(size: 22, weight: .bold)) + .foregroundColor(.white) + } + + Rectangle() + .fill(Color.gray.opacity(0.5)) + .frame(height: 1) + + ZStack { + RoundedRectangle(cornerRadius: 4) + .fill(Color.black) + .frame(width: 30, height: 30) + + Text("\(String(format: "%02d", calendar.component(.minute, from: currentTime)))") + .font(.system(size: 22, weight: .bold)) + .foregroundColor(.white) + } + } } } @@ -106,7 +113,7 @@ struct ClockPreviewView: View { private var timeFormatter: DateFormatter { let formatter = DateFormatter() - formatter.dateFormat = "HH:mm:ss" + formatter.dateFormat = "HH:mm" return formatter } @@ -147,7 +154,7 @@ struct ClockPreviewView: View { #Preview { VStack(spacing: 20) { ClockPreviewView(clockStyle: .digital) - ClockPreviewView(clockStyle: .analog) + ClockPreviewView(clockStyle: .flip) ClockPreviewView(clockStyle: .minimal) } .padding() diff --git a/image-flow/ClockStyle.swift b/image-flow/ClockStyle.swift index c663adb..c1d0af1 100644 --- a/image-flow/ClockStyle.swift +++ b/image-flow/ClockStyle.swift @@ -9,13 +9,13 @@ import Foundation enum ClockStyle: String, CaseIterable { case digital - case analog + case flip case minimal var displayName: String { switch self { case .digital: return "Digital" - case .analog: return "Analog" + case .flip: return "Flip" case .minimal: return "Minimal" } } @@ -23,7 +23,7 @@ enum ClockStyle: String, CaseIterable { var icon: String { switch self { case .digital: return "clock" - case .analog: return "clock.circle" + case .flip: return "clock.circle" case .minimal: return "clock.badge" } } diff --git a/image-flow/FlipClockViewModel.swift b/image-flow/FlipClockViewModel.swift new file mode 100644 index 0000000..9106eba --- /dev/null +++ b/image-flow/FlipClockViewModel.swift @@ -0,0 +1,35 @@ +import Foundation +import Combine + +class FlipClockViewModel: ObservableObject { + init() { + setupTimer() + } + + private(set) lazy var flipViewModels = { (0...5).map { _ in FlipViewModel() } }() + + // MARK: - Private + + private func setupTimer() { + Timer.publish(every: 1, on: .main, in: .default) + .autoconnect() + .map { [timeFormatter] in timeFormatter.string(from: $0) } + .removeDuplicates() + .sink(receiveValue: { [weak self] in self?.setTimeInViewModels(time: $0) }) + .store(in: &cancellables) + } + + private func setTimeInViewModels(time: String) { + zip(time, flipViewModels).forEach { number, viewModel in + viewModel.text = "\(number)" + } + } + + private var cancellables = Set() + + private var timeFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "HHmmss" + return formatter + } +} diff --git a/image-flow/FlipView.swift b/image-flow/FlipView.swift new file mode 100644 index 0000000..bb17758 --- /dev/null +++ b/image-flow/FlipView.swift @@ -0,0 +1,33 @@ +import SwiftUI + +struct FlipView: View { + init(viewModel: FlipViewModel) { + self.viewModel = viewModel + } + + @ObservedObject var viewModel: FlipViewModel + + var body: some View { + VStack(spacing: 0) { + ZStack { + SingleFlipView(text: viewModel.newValue ?? "", type: .top) + SingleFlipView(text: viewModel.oldValue ?? "", type: .top) + .rotation3DEffect(.init(degrees: self.viewModel.animateTop ? -90 : .zero), + axis: (1, 0, 0), + anchor: .bottom, + perspective: 0.5) + } + Color.gray.opacity(0.5) + .frame(height: 1) + ZStack { + SingleFlipView(text: viewModel.oldValue ?? "", type: .bottom) + SingleFlipView(text: viewModel.newValue ?? "", type: .bottom) + .rotation3DEffect(.init(degrees: self.viewModel.animateBottom ? .zero : 90), + axis: (1, 0, 0), + anchor: .top, + perspective: 0.5) + } + } + .fixedSize() + } +} diff --git a/image-flow/FlipViewModel.swift b/image-flow/FlipViewModel.swift new file mode 100644 index 0000000..b473cdb --- /dev/null +++ b/image-flow/FlipViewModel.swift @@ -0,0 +1,30 @@ +import Combine +import SwiftUI + +class FlipViewModel: ObservableObject, Identifiable { + var text: String? { + didSet { updateTexts(old: oldValue, new: text) } + } + + @Published var newValue: String? + @Published var oldValue: String? + + @Published var animateTop: Bool = false + @Published var animateBottom: Bool = false + + func updateTexts(old: String?, new: String?) { + guard old != new else { return } + oldValue = old + animateTop = false + animateBottom = false + + withAnimation(Animation.easeIn(duration: 0.2)) { [weak self] in + self?.newValue = new + self?.animateTop = true + } + + withAnimation(Animation.easeOut(duration: 0.2).delay(0.2)) { [weak self] in + self?.animateBottom = true + } + } +} diff --git a/image-flow/SingleFlipView.swift b/image-flow/SingleFlipView.swift new file mode 100644 index 0000000..ce04639 --- /dev/null +++ b/image-flow/SingleFlipView.swift @@ -0,0 +1,60 @@ +import SwiftUI + +struct SingleFlipView: View { + init(text: String, type: FlipType) { + self.text = text + self.type = type + } + + var body: some View { + Text(text) + .font(.system(size: 40)) + .fontWeight(.heavy) + .foregroundColor(.white) + .fixedSize() + .padding(type.padding, -20) + .frame(width: 15, height: 20, alignment: type.alignment) + .padding(type.paddingEdges, 10) + .clipped() + .background(Color.black) + .cornerRadius(4) + .padding(type.padding, -4.5) + .clipped() + } + + enum FlipType { + case top + case bottom + + var padding: Edge.Set { + switch self { + case .top: + return .bottom + case .bottom: + return .top + } + } + + var paddingEdges: Edge.Set { + switch self { + case .top: + return [.top, .leading, .trailing] + case .bottom: + return [.bottom, .leading, .trailing] + } + } + + var alignment: Alignment { + switch self { + case .top: + return .bottom + case .bottom: + return .top + } + } + } + + // MARK: - Private + private let text: String + private let type: FlipType +} diff --git a/image-flow/SlideshowView.swift b/image-flow/SlideshowView.swift index fe803b4..8738ad5 100644 --- a/image-flow/SlideshowView.swift +++ b/image-flow/SlideshowView.swift @@ -82,8 +82,8 @@ struct SlideshowView: View { switch clockStyle { case .digital: DigitalClockView(currentTime: currentTime) - case .analog: - AnalogClockView(currentTime: currentTime) + case .flip: + FlipClockView(currentTime: currentTime) case .minimal: MinimalClockView(currentTime: currentTime) } @@ -180,81 +180,46 @@ struct DigitalClockView: View { } } -struct AnalogClockView: View { +struct FlipClockView: View { let currentTime: Date - - private var calendar: Calendar { - return Calendar.current - } - - private var hour: CGFloat { - let hour = CGFloat(calendar.component(.hour, from: currentTime) % 12) - let minute = CGFloat(calendar.component(.minute, from: currentTime)) - return (hour + minute / 60) / 12 - } - - private var minute: CGFloat { - let minute = CGFloat(calendar.component(.minute, from: currentTime)) - let second = CGFloat(calendar.component(.second, from: currentTime)) - return (minute + second / 60) / 60 - } - - private var second: CGFloat { - let second = CGFloat(calendar.component(.second, from: currentTime)) - return second / 60 - } + @StateObject private var viewModel = FlipClockViewModel() var body: some View { - ZStack { - // Clock face - Circle() - .fill(Color.black.opacity(0.5)) - .frame(width: 150, height: 150) - .overlay( - Circle() - .stroke(Color.white, lineWidth: 3) - ) + GeometryReader { geometry in + let isPortrait = geometry.size.width < geometry.size.height + let scale: CGFloat = isPortrait ? 1.0 : 0.7 - // Hour markers - ForEach(0..<12) { hour in - Rectangle() - .fill(Color.white) - .frame(width: 3, height: hour % 3 == 0 ? 12 : 6) - .offset(y: -65) - .rotationEffect(.degrees(Double(hour) * 30)) + VStack { + HStack(spacing: 15) { + // Hours + HStack(spacing: 5) { + FlipView(viewModel: viewModel.flipViewModels[0]) + FlipView(viewModel: viewModel.flipViewModels[1]) + } + + // Minutes + HStack(spacing: 5) { + FlipView(viewModel: viewModel.flipViewModels[2]) + FlipView(viewModel: viewModel.flipViewModels[3]) + } + + // Seconds + HStack(spacing: 5) { + FlipView(viewModel: viewModel.flipViewModels[4]) + FlipView(viewModel: viewModel.flipViewModels[5]) + } + } + .scaleEffect(scale) + + // AM/PM indicator + Text(Calendar.current.component(.hour, from: currentTime) >= 12 ? "PM" : "AM") + .font(.system(size: 24 * scale, weight: .bold, design: .rounded)) + .foregroundColor(.white) + .padding(.top, 10 * scale) } - - // Hour hand - Rectangle() - .fill(Color.white) - .frame(width: 3, height: 45) - .cornerRadius(1.5) - .offset(y: -22.5) - .rotationEffect(.degrees(Double(hour) * 360)) - - // Minute hand - Rectangle() - .fill(Color.white) - .frame(width: 2, height: 60) - .cornerRadius(1) - .offset(y: -30) - .rotationEffect(.degrees(Double(minute) * 360)) - - // Second hand - Rectangle() - .fill(Color.red) - .frame(width: 1, height: 70) - .cornerRadius(0.5) - .offset(y: -35) - .rotationEffect(.degrees(Double(second) * 360)) - - // Center cap - Circle() - .fill(Color.white) - .frame(width: 8, height: 8) + .shadow(color: .black.opacity(0.5), radius: 5, x: 0, y: 2) + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .frame(width: 160, height: 160) - .shadow(color: .black.opacity(0.5), radius: 5, x: 0, y: 2) } }