不知不觉十一马上到了,承诺给大家分享的Swift项目也该兑现了,本次项目为高仿小日子,废话不多说,先看效果吧
探店效果图
探店详情页效果图
附近地图效果图
体验效果图
分类效果图
我的效果图
搜索效果图
摇一摇效果图
更多效果请用Xcode7.0正式版运行程序查看
此项目相对来说比较简单,之前用Swift1.2编写的,9月18号,苹果发布了Xcdoe7.0正式版,小熊也将代码更新到了Swift2.0,由于Swift语言还不稳定,每个版本都会出现语法修改,本项目用最新的Xcode7正式版编写,建议使用Xcode7正式版运行工程,项目的接口依然有加密,抱着学习的态度,小熊截取了网络请求返回数据,并且将数据写入到了本地,所以读者用到的数据都是固定的
本来想挑一个复杂点的项目分享给大家,考虑到公司的项目要在十一前上线,时间比较紧,以及自己对Swift语言还处于摸索阶段,经常也是连蒙加猜的,很多写法还是沿用OC的套路,不过相信对于大家学习Swift语言还是有一定帮助的.很多时候,小熊都是被一个很简单的语法卡住很久,比如如何动态实例化AnyClass对象,还有升级到2.0后,通过NSStringFromClass:方法创建对象是需要追加命名空间的前缀,我记忆很深刻...
此次写代码的过程中吸取了读者的提议,用的源代码管理工具是git,大家可以在项目的历史版本回退查看项目的从无到有是如何一步一步完善的,由于白天上班,晚上写这个项目,所以中间博客没有怎么更新,读者也可以从代码提交时间看出...都是半夜1 . 2点左右
在学习Swift语言中,自己也断断续续的总结了一些OC和Swift语法的区别,以及Swift于OC中相同方法的不用写法,我也会在后续时间发布到博客里,让大家避免不必要的麻烦,请持续关注小熊的博客
下面来介绍此项目的每个模块的思路,这里就按照程序运行展示的页面的顺序逐一与大家分析吧
引导页(新特性页面)
-
引导页只在用户第一次运行程序,或者APP升级后第一次运行程序时候出现,为了是提示用户APP的新功能以及改变
引导页如下图所展示的效果
-
目前比较主流的方法是使用UICollectionview来实现引导页
可以直接复用
,如果只是单纯的图片和简单的动画建议使用UICollectionview,如果有比较复杂的动画,也可以用ScrollView来实现,根据项目的不同需求来选择,这里的引导页只有一页,我就直接用的View -
在程序启动成功的方法中,首先判断用户是否是第一次运行当前版本的APP,我的做法是用版本号来判断,首先在info中拿出当前程序的版本号,在从UserDefaults取出上次用户运行程序的版本号对比,如果相等,说明没有新版本,直接进入,如果不相等则显示导引页,并且将当前版本写入UserDefaults中,以便下次用户运行APP判断.具体代码如下,返回的控制器作为keyWindow的rootViewController,如果是引导页,并且用户点击了体验按钮,可以发出通知,将KeyWindow的rootViewController切换即可
privete func showLeadpage() -> UIViewController {
let versionStr = "CFBundleShortVersionString"
let cureentVersion = NSBundle.mainBundle().infoDictionary![versionStr] as! String
let oldVersion = (NSUserDefaults.standardUserDefaults().objectForKey(versionStr) as? String) ?? ""
if cureentVersion.compare(oldVersion) == NSComparisonResult.OrderedDescending {
NSUserDefaults.standardUserDefaults().setObject(cureentVersion, forKey: versionStr)
NSUserDefaults.standardUserDefaults().synchronize()
return LeadpageViewController()
}
UITabbarController(KeyWindow.rootViewController)
- 这里小熊自己定义的MainTabBarController继承至UITabBarController,里面装了四个MainNavigationController
继承UINavigationController
作为子控制器 - MainNavigationController
继承UINavigationController
,重写了pushViewController方法,方便统一管理返回按钮和每次Push出新的控制器,自动隐藏底部的TabBar,具体代码如下
override func pushViewController(viewController: UIViewController, animated: Bool) {
if self.childViewControllers.count > 0 {
let vc = self.childViewControllers[0]
if self.childViewControllers.count == 1 {
backBtn.setTitle(vc.tabBarItem.title!, forState: .Normal)
} else {
backBtn.setTitle("返回", forState: .Normal)
}
viewController.navigationItem.leftBarButtonItem = UIBarButtonItem(customView: backBtn)
viewController.hidesBottomBarWhenPushed = true
}
super.pushViewController(viewController, animated: animated)
}
左上角的城市
- 由于探店, 体验, 分类都有城市选择按钮,并且一个改变,其余的也都会改变,我的做法是提供一个父类,MainViewController
继承至UIViewController
,设置navigationItem.leftBarButtonItem为自定义的按钮,并且在viewDidLoad:方法中,添加通知,监听city的改变,一旦监听到当前城市发生改变的通知后,对应的控制器就可以执行对应的操作,需要注意的是
每次要将改变后的城市写入到本地持久化存储,以便程序被关闭后再次运行时,可以保留上次用户所选择的城市 - 选择城市的控制器是CityViewController
如下图
,城市的展示小熊用的UICollectionview来展示的,需要注意每次ViewController弹出后,用户上一次选择的城市都会自动进入选中状态,具体实现是拿出上一次用户选择的城市,遍历城市列表取出所属的indexPath,然后执行下面方法
let lastSelectedCityIndexPaht = selectedCurrentCity()
collView.selectItemAtIndexPath(lastSelectedCityIndexPaht, animated: true, scrollPosition: UICollectionViewScrollPosition.None)
城市选择控制器
- 当用户再次选择城市后,将城市写入到本地保存,并且发出城市改变的通知,通知监听者做出相应的操作
探店如下图所示
探店效果图
-
导航条上的美天和美辑是将navigationItem.titleView 设置为自定义的DoubleTextView来实现,内部封装好功能,并且通过设置代理将点击事件传递给控制器,顺便提一嘴,Swift中的代理和OC中的一样,也是采用软引用即:
weak var delegate: <Delegate>?
,提供一个方法便于在scrollView的offsetX发生改变时更新内部控件状态具体实现请参考项目中的代码
-
在view的最底层添加一个scrollView,设置scorllView的contentSize为屏幕的宽度的2倍,在scrollView上添加俩个TableView,分别是美天的TableView和美辑的TabelView,注意观察,美天tableViewCell有一种cell的样式于美辑的样式是一样的,所以这里只用了两种cell,参考下图,这里是根据服务器返回数据来判断加载哪一种cell,美天的tableView采用的是样式是Group样式,每一组有一个或者多个cell
EventCell样式效果图
ThemeCell样式效果图 -
在TableView的代理方法中,根据服务器返回的数据创建对应的cell,喜欢专研的朋友可以查看项目中CustomData文件中的events.json文件查看规律
-
这里提一下Swift的闭包方法中,需要像OC中一样创建一个weak的指针weak var tmpSelf = self,在闭包方法中调用tmpSelf,直接调用self会引起循环引用,照成内存泄露,控制器无法被销毁问题
-
点击EventCell弹出的详情页我着重讲一下,先看效果图
美天详情效果图 -
顶部的导航条是自定义的view,在EventViewController的viewWillAppaer方法中,将系统的导航条隐藏掉,添加定义的view作为导航条,按钮也是如此,根据scrollView的偏移量计算透明度,图片的拉伸和按钮的图片改变,由于有俩个scrollView,有些效果需要判断不同scrollView来执行,需要注意的是适当添加标记来防止重复判断,消耗内存
-
店发现和店详情掌握之前提的导航条titelView也可以轻松搞定,位置也是根据scrollView的Y轴的偏移量来计算,代码并不复杂,具体代码如下
extension EventViewController: UIScrollViewDelegate {
func scrollViewDidScroll(scrollView: UIScrollView) {
// 解决弹出新的控制器后返回后contentSize自动还原的问题
if loadFinishScrollHeihgt > webView.scrollView.contentSize.height && scrollView === webView.scrollView {
webView.scrollView.contentSize.height = loadFinishScrollHeihgt
}
let offsetY: CGFloat = scrollView.contentOffset.y
// 判断顶部自定义导航条的透明度,以及图片的切换
customNav.alpha = 1 + (offsetY + NavigationH + EventViewController_ShopView_Height) / scrollShowNavH
if offsetY + EventViewController_ShopView_Height >= -NavigationH && showBlackImage == false {
backBtn.setImage(UIImage(named: "back_1"), forState: .Normal)
likeBtn.setImage(UIImage(named: "collect_1"), forState: .Normal)
sharedBtn.setImage(UIImage(named: "share_1"), forState: .Normal)
showBlackImage = true
} else if offsetY < -NavigationH - EventViewController_ShopView_Height && showBlackImage == true {
backBtn.setImage(UIImage(named: "back_0"), forState: .Normal)
likeBtn.setImage(UIImage(named: "collect_0"), forState: .Normal)
sharedBtn.setImage(UIImage(named: "share_0"), forState: .Normal)
showBlackImage = false
}
// 顶部imageView的跟随动画
if offsetY <= -DetailViewController_TopImageView_Height - EventViewController_ShopView_Height {
topImageView.frame.origin.y = 0
topImageView.frame.size.height = -offsetY - EventViewController_ShopView_Height
topImageView.frame.size.width = AppWidth - offsetY - DetailViewController_TopImageView_Height
topImageView.frame.origin.x = (0 + DetailViewController_TopImageView_Height + offsetY) * 0.5
} else {
topImageView.frame.origin.y = -offsetY - DetailViewController_TopImageView_Height - EventViewController_ShopView_Height
}
// 处理shopView
if offsetY >= -(EventViewController_ShopView_Height + NavigationH) {
shopView.frame = CGRect(x: 0, y: NavigationH, width: AppWidth, height: EventViewController_ShopView_Height)
} else {
shopView.frame = CGRect(x: 0, y: CGRectGetMaxY(topImageView.frame), width: AppWidth, height: EventViewController_ShopView_Height)
}
// 记录scrollView最后的偏移量,用于切换scrollView时同步俩个scrollView的偏移值
lastOffsetY = offsetY
}
}
-
店发现 这里相对来说有点麻烦,这里服务器返回的是好长的一串htmlString,由于没有css的布局文件,直接加载htmlStr的话图片是超级的大,有的像素达到4480*3000,直接用webView.loadHTMLString:的方法很不现实,这里我放一段服务器返回的字符串给读者看一下
html返回的字符串如下图
-
由于没有css的布局文件,只能靠自己来约束图片的大小,我采用的方法是通过正则语句过滤出所有图片的宽和高,然后按照屏幕的宽度等比例计算图片的大小,在将修改过的宽和高替换插入到原有的htmlStr中,观察服务器返回的htmlStr是没有title和tag的,也就意味这展示的内容没有标题和标签,所以再动态的将title以及tag的标签插入到字符串中
插入代码如下
if model?.title != nil {
titleStr = String(format: "<p style='font-size:20px;'> %@</p>", model!.title!)
}
if model?.tag != nil {
titleStr = titleStr?.stringByAppendingFormat("<p style='font-size:13px; color: gray';>%@</p>", model!.tag!)
}
- 再加载修改过图片宽高的htmlStr,然后webViewDidFinishLoad的方法中,通过JS代码将内容的背景色修改,此种方法瑕疵很多,代码如下
webView.stringByEvaluatingJavaScriptFromString("document.getElementsByTagName('body')[0].style.background='#F5F5F5';")
-
对webView图片的处理,小熊感觉还有一个更好的思路可以尝试,不过在项目中没有实现成功,这里我觉得可以动态的将所有的的htmlStr中image都拦截掉,不让webView自己加载图片,并且通过条件过滤保留图片的url地址,动态的计算出图片的位置和大小,用本地加载图片的方法加载图片,放到对应webView.scrollView的位置,这样就可以实现缓存,以及点击等事件,不过需要于js端的配合,这个以后我一定会再尝试的,可行的话我会将demo分享给大家
-
在webView的最底部,也会根据服务器返回的数据动态的加入猜你喜欢的cell,这里只需要在webView加载完毕后,确认webView.scrollView.contentSize.height后面添加对应的moreView即可!
需要注意的是,添加内容后,也要相应的增加webView.scrollView.height的高度用来显示新添加的控件
附近(效果如下图所示)
附近地图效果图
- 在程序启动成功的方法中,首先会获取用户授权使用定位功能,这里我封装了一个管理用户位置的单例,当用户授权App可以使用定位后,获取用户当前的经纬度并且将用户位置的经纬度保存,当用户点击附近按钮弹出控制器时,发送网络请求给服务器,服务器通过用户当前的经纬度返回附近店铺的信息,由于没有数据,所以每次返回的都是小熊写在本地的数据,解析对应的数据加载到tableView显示,通过用户的位置以及店铺的位置可以计算出距离给label设置用户当前距离店铺的距离
KM
- 翻转动画,根据右上角按钮的状态使用下面方法完成翻转动画
if sender.selected {
UIView.transitionFromView(nearTableView, toView: mapView, duration: 1.0, options: UIViewAnimationOptions.TransitionFlipFromLeft, completion: nil)
} else {
UIView.transitionFromView(mapView, toView: nearTableView, duration: 1.0, options: UIViewAnimationOptions.TransitionFlipFromRight, completion: nil)
}
- mapView中,有一个UICollectionview,一个定位到当前用户位置的按钮,以及自定义的大头针若干,判断大头针的选中状态切换大头针的图片,根据选中的shopCell切换选中大头针等,都是一些计算性的代码,具体代码参考
WNXMapView.swift
文件,点击shopCell推出对应的控制器即可
体验
- 体验控制器值得一提的是TableViewCell的高度计算方法采用了iOS8.0的新方法,通过约束cell底部来自动计算cell的高度,在xib中约束好cell底部后,在初始化TableView时,通过下面俩句代码就可以实现cell自动计算高度
// 估算cell的高度
tableV.estimatedRowHeight = 200
// 设置tableView的自动布局样式
tableV.rowHeight = UITableViewAutomaticDimension
- 这个方法局限性比较大,只可以在确定cell底部约束对象时才可以采用,根据需求情况有时使用会比较方便
必须iOS8.0以上才可以
体验详情控制器效果如下图所示
- 可以看出体验详情控制器与探店详情控制器大同小异,不同的是底部动态添加的控件.
- 报名控制器需要注意键盘对TextField的遮盖,监听键盘将要改变Frame的通知,设置scrollView的偏移Y轴的偏移值,保证键盘无法遮盖住输入框,网网项目中最耗时的就是这些细节性的动画,既考验开发人员的细心,又考验开发人员的耐心
- 当用户输入好报名信息并且点击提交后,将报名信息通过sql语句插入到本地数据库,为了给后面我的订单中使用
搜索控制器(效果图如下)
搜索控制器
- 这里有个小小的动画,是根据键盘监听键盘Frame改变发出的通知来做出相应的动画,值得提醒大家的是:通过真机调试发现,当用户手机安装三方的键盘时
(搜狗输入法...)
,键盘弹出和隐藏的通知会连续发出多次,需要加上标记来判断即可,模拟器中我还没发现这种情况,望大家注意
我的
用户头像View
-
首先利用封装的用户账号管理工具判断当前用户是否登陆,如果未登录,则推出登陆界面,当用户登陆或者注册后,将用户的账号,以及加密过的密码发送给服务器,以及在本地备份,便于程序关闭后,下次运行程序时,用户不需要再次登陆.
-
如果用户已登录并且点击iconView后,打开UIImagePickerController,根据用户选择的是打开相册还是相机来设置UIImagePickerController对象的sourceType.当用户点击相机时,需要先判断用户当前摄像头是否可用,
注意模拟器没有摄像头,需要在真机调试才可以打开摄像头
,设置照片为可编辑模式,这样用户选择的图片将会是正方形,方便头像的圆形的裁剪 -
这里有一个地方值得注意,就是随着相机像素的提高,实际用户选择的图片都是很大的,有的高达5.6M,如果直接使用用户选着的图片,非常消耗内存,并且也用不到这么高像素的图片,可以当用户选着好图片后,在UIImagePickerController对应的代理方法中,先将图片进行重新绘制为需要的大小,在设置给iconView
具体代码如下所示
/// MARK: 摄像机和相册的操作和代理方法
extension MeViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
/// 打开照相功能
private func openCamera() {
if UIImagePickerController.isSourceTypeAvailable(.Camera) {
pickVC.sourceType = .Camera
self.presentViewController(pickVC, animated: true, completion: nil)
} else {
SVProgressHUD.showErrorWithStatus("模拟器没有摄像头,请链接真机调试", maskType: SVProgressHUDMaskType.Black)
}
}
/// 打开相册
private func openUserPhotoLibrary() {
pickVC.sourceType = .PhotoLibrary
pickVC.allowsEditing = true
presentViewController(pickVC, animated: true, completion: nil)
}
func imagePickerController(picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [String : AnyObject]) {
// 对用户选着的图片进行质量压缩,上传服务器,本地持久化存储
if let typeStr = info[UIImagePickerControllerMediaType] as? String {
if typeStr == "public.image" {
if let image = info[UIImagePickerControllerEditedImage] as? UIImage {
var data: NSData?
let smallImage = UIImage.imageClipToNewImage(image, newSize: iconView!.iconButton.size)
if UIImagePNGRepresentation(smallImage) == nil {
data = UIImageJPEGRepresentation(smallImage, 0.8)
} else {
data = UIImagePNGRepresentation(smallImage)
}
if data != nil {
do {
// TODO: 将头像的data传入服务器
// 本地也保留一份data数据
try NSFileManager.defaultManager().createDirectoryAtPath(theme.cachesPath, withIntermediateDirectories: true, attributes: nil)
} catch _ {
}
NSFileManager.defaultManager().createFileAtPath(SD_UserIconData_Path, contents: data, attributes: nil)
iconView!.iconButton.setImage(UIImage(data: NSData(contentsOfFile: SD_UserIconData_Path)!)!.imageClipOvalImage(), forState: .Normal)
} else {
SVProgressHUD.showErrorWithStatus("照片保存失败", maskType: SVProgressHUDMaskType.Black)
}
}
}
} else {
SVProgressHUD.showErrorWithStatus("图片无法获取", maskType: SVProgressHUDMaskType.Black)
}
picker.dismissViewControllerAnimated(true, completion: nil)
}
func imagePickerControllerDidCancel(picker: UIImagePickerController) {
pickVC.dismissViewControllerAnimated(true, completion: nil)
}
}
摇一摇
摇一摇效果图- 摇一摇使用的是苹果提供好的方法override func motionBegan(motion: UIEventSubtype, withEvent event: UIEvent?),每当用户摇晃手机时,系统就会自动调用此方法,在方法内部做出相应的动画即可
关键代码就这下面这几句,如果是使用模拟器可以点击窗口上**Hardware -> Shake Gesture**来进行模拟调试
override func motionBegan(motion: UIEventSubtype, withEvent event: UIEvent?) {
tableView!.hidden = true
let animateDuration: NSTimeInterval = 0.3
let offsetY: CGFloat = 50
UIView.animateWithDuration(animateDuration, animations: { () -> Void in
self.yaoImageView1.transform = CGAffineTransformMakeTranslation(0, -offsetY)
self.yaoImageView2.transform = CGAffineTransformMakeTranslation(0, offsetY)
}) { (finish) -> Void in
let popTime = dispatch_time(DISPATCH_TIME_NOW,Int64(0.5 * Double(NSEC_PER_SEC)))
dispatch_after(popTime, dispatch_get_main_queue(), { () -> Void in
UIView.animateWithDuration(animateDuration, animations: { () -> Void in
self.yaoImageView1.transform = CGAffineTransformIdentity
self.yaoImageView2.transform = CGAffineTransformIdentity
}, completion: { (finish) -> Void in
self.loadShakeData()
// 音效
AudioServicesPlayAlertSound(self.soundID!)
})
})
}
清理缓存
- 这里我封装了一个工具类:FileTool,通过类方法可以调用查看指定路径文件夹的大小
FileTool.folderSize(path: String)
,以及异步删除指路径下的全部文件夹FileTool.cleanFolder(path: String, complete: () -> ())
,complete为删除完成后的回调
三方分享
- 这里我封装了一个分享工具类ShareTool,集成了新浪SSO,微信SSO认证,以及新浪OAuth认证.采用的是友盟分享SDK,以及自定义的分享界面,需要注意的是由于微信没有OAuth认证,所以只有在设备安装了微信时才可以分享(
插上你的真机分享点击分享试一试
),模拟器中只可以使用新浪微博的分享. - 如果想要将新浪分享的来自xx和图片修改为自己应用的图标,可以在新浪的开发者平台注册成为开发者,添加应用并且上线,通过审核,在友盟网站我的产品中将设置主题修改相应的属性即可.
- 在iOS9.0后,需要设置白名单以及回调的白名单才可以完成分享后的回调
关于项目的介绍基本就这么多了,更多的细节和内容直接看代码来的更快捷一些,对于Swift小熊也是又爱又恨,相信随着版本的不断更新,Swift语言也会越来越趋于稳定,Xcode对Swift的支持也会越来越好的,总算对自己的承诺有了一个小小的交代,如果有小熊的项目有帮助到大家,请到我的GitHub点个星,项目中会有很多不足之处,欢迎到我的博客留言与交流,小熊希望与大家共同进步_~,提前祝大家十一快乐
直接打开运行工程
打开工作组运行工程
网友评论
然而xcode8来编译,错误一大堆一大堆。
新手表示不会弄
有没有群,交流一下~
我好佩服你能独立写出这么优秀的代码呀。我刚开始学ios开发,你的代码木有storyboard实在没法看懂,所以我想问有办法根据你的代码生成一个storyboard出来吗?
net.requestJSON(.GET, "http://api.xiaorizi.me/api/catapi/", ["cityid" : "101", "offset" : "30", "page" : "1", "token_time" : "1440170554", "app_token" : "D2104E5F31726F2C", "version" : "2.3.9", "channel" : "iTunes", "uuid" : "DDB32FF0-2140-4C7E-BC49-8464D2046532"]) { (result, error) -> () in
print(result) }
http://api.xiaorizi.me/api/catapi/
这个接口的代码,能共享么?
我的qq:25281623@qq.com
我读你的swift代码的时候发现一个问题:按照我的理解,变量或者属性,应该先定义,然后才可以去引用,去使用,对不?
你的代码EveryDayModel.swift中的第44行,引用了self.month.但是month这个属性是在第90行定义的。我没搞明白,往解惑,谢谢!!
这时候右滑返回无效了,请问您是怎么解决的?
let selectedImage = UIImage(named: selectedImageName)!.imageWithRenderingMode(UIImageRenderingMode.AlwaysOriginal)
你是怎么处理的
我把你的自定义的MainTabBar 注销都没渲染图片??
clang: error: linker command failed with exit code 1 (use -v to see invocation)
Referenced from: /var/mobile/Containers/Bundle/Application/D30512BF-E2FF-4A6A-BA04-36BEF6F7F72B/SmallDay.app/SmallDay
Reason: no suitable image found. Did find:
/private/var/mobile/Containers/Bundle/Application/D30512BF-E2FF-4A6A-BA04-36BEF6F7F72B/SmallDay.app/Frameworks/libswiftAVFoundation.dylib: mmap() errno=1 validating first page of '/private/var/mobile/Containers/Bundle/Application/D30512BF-E2FF-4A6A-BA04-36BEF6F7F72B/SmallDay.app/Frameworks/libswiftAVFoundation.dylib' 这是我运行时出现的错误,libswiftAVAVFoundation.dylib 这个库在哪里呀
github 上已经star, 加油 .
直接创建一个empty的swift文件就可以了
另一方面,开头一大段都用三级标题也不妥,要不也修改一下吧~~~