mirror of
https://github.com/wahyd4/image-flow.git
synced 2026-08-08 20:56:05 +10:00
Add flip
This commit is contained in:
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AnyCancellable>()
|
||||
|
||||
private var timeFormatter: DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HHmmss"
|
||||
return formatter
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user